diff --git a/docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md b/docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md new file mode 100644 index 000000000..53a010896 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md @@ -0,0 +1,139 @@ +# Sprint 003 - Host UI + Environment Verification + +## Topic & Scope +- Surface runtime probe state on the topology hosts page so operators can see which hosts are actually monitored. +- Replace the stub host detail route with a usable host page that shows mapped targets, probe guidance, and recent activity. +- Move the environment verification work onto the current topology environment detail route instead of the older release-orchestrator casefile. +- Keep runtime verification truthful: ship probe-backed and drift-backed UI now, and degrade cleanly when container-level evidence is not available yet. +- Working directory: `src/Web/StellaOps.Web/src/app/`. +- Expected evidence: Angular build success, probe status visible on hosts page, host detail page functional, runtime verification visible on topology environment detail. + +## Dependencies & Concurrency +- Depends on Sprint 002 for enriched probe/runtime evidence: + - Topology hosts API with probe status fields. + - Follow-on runtime/container evidence API for true running-vs-deployed digest comparison. +- Current canonical environment detail route is `src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts`. +- `src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts` is not the live user-facing route for this scope and should not receive new verification UX. +- Host detail and probe UX can ship before full backend completion as long as missing probe/container data is rendered as explicit degraded states rather than fabricated success. +- Reuse existing UI pieces where possible: + - `src/Web/StellaOps.Web/src/app/shared/ui/status-badge/status-badge.component.ts` + - `src/Web/StellaOps.Web/src/app/shared/ui/copy-to-clipboard/copy-to-clipboard.component.ts` + - `src/Web/StellaOps.Web/src/app/shared/pipes/format.pipes.ts` + +## Documentation Prerequisites +- `.claude/plans/buzzing-napping-ember.md` +- `src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts` +- `src/Web/StellaOps.Web/src/app/features/topology/topology-host-detail-page.component.ts` +- `src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts` +- `src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts` + +## Delivery Tracker + +### TASK-001 - Add runtime probe state to topology hosts page +Status: DONE +Dependency: Sprint 002 topology host probe fields +Owners: Developer (FE) +Task description: +- Extend the topology host model with optional probe status, probe type, and probe heartbeat fields. +- Add a `Runtime Probe` column and a `Last Seen` column to the hosts table. +- Render probe states as explicit badges: `Active`, `Offline`, `Not installed`. +- Add a runtime-probe filter so the page can be scoped to monitored, unmonitored, or all hosts. +- Degrade to `Not monitored` when backend probe data is absent. + +Completion criteria: +- [x] `TopologyHost` model extended with probe fields. +- [x] Hosts table shows runtime probe and last-seen columns. +- [x] Active probes show success badge with probe type label. +- [x] Offline probes show error badge. +- [x] Unmonitored hosts show neutral `Not installed`. +- [x] Probe filter scopes hosts correctly. +- [x] Angular build succeeds. + +### TASK-002 - Replace the host detail stub with a route-backed host detail page +Status: DONE +Dependency: TASK-001 +Owners: Developer (FE) +Task description: +- Rewrite `features/topology/topology-host-detail-page.component.ts` into a usable host detail page. +- Page sections: + 1. Host overview header with host name, region, environment, runtime, health, and last seen. + 2. Connection profile panel with derived SSH/WinRM/Docker family summary and truthful fallback when exact backend config is not exposed. + 3. Mapped targets table with links to target detail. + 4. Runtime probe panel with install guidance, copyable commands, and active/offline state. + 5. Recent activity section derived from mapped target sync activity. +- The install panel may include a local command-preview toggle for enabling runtime verification, but must not pretend to persist host configuration without backend support. + +Completion criteria: +- [x] Host detail page renders all five sections. +- [x] Connection panel shows a truthful connection profile summary. +- [x] Mapped targets link to target detail routes. +- [x] Probe installation guidance appears for unmonitored hosts. +- [x] Copy-to-clipboard works for install commands. +- [x] Active or offline probe state shows heartbeat context. +- [x] Page loads from direct URL and from host-list navigation. + +### TASK-003 - Add runtime verification state to topology environment targets +Status: DONE +Dependency: Sprint 002 probe enrichment. Graceful fallback allowed before container evidence exists. +Owners: Developer (FE) +Task description: +- Modify `features/topology/topology-environment-detail-page.component.ts`. +- Add a `Runtime` column to the canonical Targets tab. +- Badge states: `Verified`, `Drift`, `Offline`, `Not monitored`. +- Current signal is derived from host probe heartbeat plus the dominant deployed release version in the environment. +- Tooltip text must explain the signal and degrade cleanly when probe/runtime evidence is missing. + +Completion criteria: +- [x] Runtime column visible in topology environment Targets tab. +- [x] Verified targets show success badge. +- [x] Drift targets show warning badge with summary tooltip. +- [x] Offline probes show error badge. +- [x] Unmonitored targets show neutral badge. +- [x] Column degrades cleanly when probe data is unavailable. +- [x] Angular build succeeds. + +### TASK-004 - Add runtime verification breakdown to topology environment Drift tab +Status: DONE +Dependency: TASK-003 +Owners: Developer (FE) +Task description: +- Modify `features/topology/topology-environment-detail-page.component.ts`. +- Add a `Runtime Verification` section to the Drift tab below the existing drift summary. +- Show a per-target matrix with host, probe state, expected release version, observed release version, image digest, and runtime state. +- Highlight drift, offline, and unmonitored rows distinctly. +- Make the section collapsible with a summary header. +- Keep the UI truthful: do not claim container-level running-vs-deployed digest verification until a backend endpoint returns actual running inventory evidence. + +Completion criteria: +- [x] Runtime Verification section visible in topology environment Drift tab. +- [x] Per-target matrix shows release/image context plus runtime state. +- [x] Verified targets show success status. +- [x] Drift rows show warning styling. +- [x] Offline or unmonitored rows show degraded styling. +- [x] Summary header shows counts. +- [x] Section collapses and expands. +- [x] Angular build succeeds. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-31 | Sprint created from host UI and environment verification plan. | Planning | +| 2026-03-31 | Re-scoped environment verification work onto topology routes because the older release-orchestrator environment casefile is not the canonical live path. | Implementer | +| 2026-03-31 | Implemented runtime probe coverage on topology hosts, replaced the host-detail stub, and added runtime verification to topology environment Targets and Drift tabs. | Implementer | +| 2026-03-31 | Verified `npx ng build --configuration development`, `npx tsc -p tsconfig.app.json --noEmit`, and focused `vitest.codex.config.ts` topology specs. | Implementer | +| 2026-03-31 | Synced topology component docs under `docs/modules/ui/component-preservation-map/components/weak-route/features/topology/`. | Implementer | + +## Decisions & Risks +- Decision: Runtime verification work lands on topology routes because those are the live environment and host surfaces. +- Decision: Missing backend probe/container evidence must render as explicit degraded states such as `Not monitored`, never as fabricated success. +- Decision: Host install guidance uses platform-appropriate one-liners with copy support. +- Decision: Documentation sync for this sprint lives in `docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyHostDetailPageComponent.md` and `docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyEnvironmentDetailPageComponent.md`. +- Risk: Sprint 002 currently exposes probe fields in contracts, but the topology read model may still return null probe data. Mitigation: explicit fallback UI and no false verification claims. +- Risk: True container-level digest comparison still needs a backend endpoint with running inventory evidence. Mitigation: ship host/probe/drift-backed verification first and keep the deeper comparison as follow-on scope. +- Risk: `ng test --include ...` still pulls unrelated legacy suites and pre-existing failures from outside this sprint. Mitigation: use focused `vitest.codex.config.ts` topology specs plus `ng build` for this sprint's evidence until the broader test surface is repaired. + +## Next Checkpoints +- Host list shows runtime probe badges and filtering. +- Host detail route shows mapped targets plus probe guidance. +- Topology environment Targets tab shows runtime verification states. +- Topology environment Drift tab shows verification summary and breakdown. diff --git a/docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyEnvironmentDetailPageComponent.md b/docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyEnvironmentDetailPageComponent.md index bf4b4e026..a4a6a62a7 100644 --- a/docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyEnvironmentDetailPageComponent.md +++ b/docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyEnvironmentDetailPageComponent.md @@ -10,13 +10,15 @@ - Selector: `app-topology-environment-detail-page` ## What Is It? -Topology Environment Detail Page appears to be a detail panel or supporting drill-down surface in the Topology area. +Topology Environment Detail Page is the canonical environment drill-down reused across topology, operations, and releases routes. It now shows runtime verification on the Targets tab and a runtime-verification breakdown on the Drift tab using probe heartbeat plus dominant deployed release state. ## Why It Likely Fell Out Of The Product -The component is still routed, but the current scan did not find an obvious menu or absolute page-action path to it. +The route remains a drill-down rather than a top-level navigation leaf, but it is an active supported surface. Sprint 003 extended it with runtime verification so operators can see verified, drift, offline, and not-monitored targets without leaving the environment detail flow. ## What Is Worth Preserving -Preserve the underlying workflow only if current product docs still claim the capability or if the component contains unique UI concepts. +- Preserve the topology-first environment posture workflow. +- Preserve the runtime verification column on Targets and the collapsible runtime breakdown on Drift. +- Preserve the explicit degraded states when probe or runtime evidence is missing. ## Likely Successor Or Merge Target Needs branch-level review against current IA @@ -40,13 +42,16 @@ Needs branch-level review against current IA - none ### Runtime references outside routes/tests -- none +- `src/Web/StellaOps.Web/src/app/routes/operations.routes.ts` +- `src/Web/StellaOps.Web/src/app/routes/releases.routes.ts` +- `src/Web/StellaOps.Web/src/app/routes/topology.routes.ts` ## Related Docs - [docs/modules/ui/README.md](../../../../../README.md) - [docs/modules/ui/architecture.md](../../../../../architecture.md) +- [docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md](../../../../../../../docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md) ## Next-Pass Questions -- Confirm whether the route is reachable only through relative child-tab navigation. -- Check the corresponding product/docs promise before treating the page as dropped. -- Verify whether the route should be linked from the current shell or intentionally remain deep-linked only. +- Confirm whether the environment detail route should gain a more explicit shell entry from inventory views. +- Replace dominant-release heuristics with backend running-inventory evidence when Sprint 002 follow-on APIs land. +- Decide whether runtime verification should be promoted into overview KPIs after backend evidence becomes richer. diff --git a/docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyHostDetailPageComponent.md b/docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyHostDetailPageComponent.md index 544d7f89b..f4dc832c4 100644 --- a/docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyHostDetailPageComponent.md +++ b/docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyHostDetailPageComponent.md @@ -10,13 +10,15 @@ - Selector: `app-topology-host-detail-page` ## What Is It? -Topology Host Detail Page appears to be a detail panel or supporting drill-down surface in the Topology area. +Topology Host Detail Page is the host drill-down for topology inventory. It now renders a route-backed host overview with connection profile, mapped targets, runtime probe status, install guidance for unmonitored hosts, and recent target-sync activity. ## Why It Likely Fell Out Of The Product -The component is still routed, but the current scan did not find an obvious menu or absolute page-action path to it. +The route is still a deep-link rather than a top-level navigation item, but it is no longer a dormant stub. Sprint 003 wired it from the topology hosts page and turned it into the primary host inspection surface for runtime coverage. ## What Is Worth Preserving -Preserve the underlying workflow only if current product docs still claim the capability or if the component contains unique UI concepts. +- Preserve the host-level runtime probe workflow and degraded-state handling. +- Preserve the mapped-target drill-down and recent activity summary. +- Preserve the copy-safe install guidance for hosts that do not yet report a probe heartbeat. ## Likely Successor Or Merge Target Needs branch-level review against current IA @@ -33,16 +35,17 @@ Needs branch-level review against current IA - none ### Absolute page-action surfaces -- none +- Host links on `TopologyHostsPageComponent` route into this detail page. ### Runtime references outside routes/tests -- none +- `src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts` ## Related Docs - [docs/modules/ui/README.md](../../../../../README.md) - [docs/modules/ui/architecture.md](../../../../../architecture.md) +- [docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md](../../../../../../../docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md) ## Next-Pass Questions -- Confirm whether the route is reachable only through relative child-tab navigation. -- Check the corresponding product/docs promise before treating the page as dropped. -- Verify whether the route should be linked from the current shell or intentionally remain deep-linked only. +- Decide whether the host detail page should gain a direct shell affordance beyond the hosts-table deep link. +- Confirm when backend host connection metadata can replace the current truthful derived summary. +- Confirm when probe telemetry expands beyond heartbeat state into richer health counters. diff --git a/src/Web/StellaOps.Web/src/app/core/testing/topology-environment-detail-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/topology-environment-detail-page.component.spec.ts new file mode 100644 index 000000000..d147e3659 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/testing/topology-environment-detail-page.component.spec.ts @@ -0,0 +1,238 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { Title } from '@angular/platform-browser'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { TopologyEnvironmentDetailPageComponent } from '../../features/topology/topology-environment-detail-page.component'; +import { BreadcrumbService } from '../../layout/breadcrumb'; + +describe('TopologyEnvironmentDetailPageComponent', () => { + let httpMock: HttpTestingController; + + beforeEach(() => { + const paramMap = convertToParamMap({ environmentId: 'prod' }); + const queryParamMap = convertToParamMap({}); + + TestBed.configureTestingModule({ + imports: [TopologyEnvironmentDetailPageComponent], + providers: [ + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { + provide: ActivatedRoute, + useValue: { + paramMap: of(paramMap), + queryParamMap: of(queryParamMap), + snapshot: { + paramMap, + queryParamMap, + data: {}, + }, + }, + }, + { + provide: PlatformContextStore, + useValue: { + initialize: () => undefined, + selectedRegions: () => [], + selectedEnvironments: () => [], + regionSummary: () => 'All regions', + environmentSummary: () => 'All environments', + }, + }, + { + provide: BreadcrumbService, + useValue: { + setContextCrumbs: () => undefined, + clearContextCrumbs: () => undefined, + }, + }, + { + provide: Title, + useValue: { + setTitle: () => undefined, + }, + }, + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('shows runtime verification on the Targets and Drift tabs', () => { + const fixture = TestBed.createComponent(TopologyEnvironmentDetailPageComponent); + const component = fixture.componentInstance; + + httpMock.expectOne((req) => req.url === '/api/v2/topology/environments' && req.params.get('environment') === 'prod').flush({ + items: [ + { + environmentId: 'prod', + regionId: 'eu-west', + environmentType: 'production', + displayName: 'Production', + sortOrder: 1, + targetCount: 3, + hostCount: 3, + agentCount: 3, + promotionPathCount: 0, + workflowCount: 0, + lastSyncAt: '2026-03-31T10:00:00Z', + }, + ], + }); + httpMock.expectOne((req) => req.url === '/api/v2/topology/targets' && req.params.get('environment') === 'prod').flush({ + items: [ + { + targetId: 'target-a', + name: 'api-a', + regionId: 'eu-west', + environmentId: 'prod', + hostId: 'host-a', + agentId: 'agent-a', + targetType: 'docker_host', + healthStatus: 'healthy', + componentVersionId: 'cmp-a', + imageDigest: 'sha256:digest-a', + releaseId: 'bundle-1', + releaseVersionId: 'release-a', + lastSyncAt: '2026-03-31T10:00:00Z', + }, + { + targetId: 'target-b', + name: 'api-b', + regionId: 'eu-west', + environmentId: 'prod', + hostId: 'host-b', + agentId: 'agent-b', + targetType: 'docker_host', + healthStatus: 'healthy', + componentVersionId: 'cmp-b', + imageDigest: 'sha256:digest-b', + releaseId: 'bundle-1', + releaseVersionId: 'release-b', + lastSyncAt: '2026-03-31T09:55:00Z', + }, + { + targetId: 'target-c', + name: 'api-c', + regionId: 'eu-west', + environmentId: 'prod', + hostId: 'host-c', + agentId: 'agent-c', + targetType: 'docker_host', + healthStatus: 'healthy', + componentVersionId: 'cmp-c', + imageDigest: 'sha256:digest-c', + releaseId: 'bundle-1', + releaseVersionId: 'release-a', + lastSyncAt: '2026-03-31T09:45:00Z', + }, + ], + }); + httpMock.expectOne((req) => req.url === '/api/v2/topology/hosts' && req.params.get('environment') === 'prod').flush({ + items: [ + { + hostId: 'host-a', + hostName: 'alpha.stella.local', + regionId: 'eu-west', + environmentId: 'prod', + runtimeType: 'docker_host', + status: 'healthy', + agentId: 'agent-a', + targetCount: 1, + lastSeenAt: '2026-03-31T10:00:00Z', + probeStatus: 'active', + probeType: 'ebpf', + probeLastHeartbeat: '2026-03-31T10:00:00Z', + }, + { + hostId: 'host-b', + hostName: 'beta.stella.local', + regionId: 'eu-west', + environmentId: 'prod', + runtimeType: 'docker_host', + status: 'healthy', + agentId: 'agent-b', + targetCount: 1, + lastSeenAt: '2026-03-31T09:55:00Z', + probeStatus: 'active', + probeType: 'ebpf', + probeLastHeartbeat: '2026-03-31T09:55:00Z', + }, + { + hostId: 'host-c', + hostName: 'gamma.stella.local', + regionId: 'eu-west', + environmentId: 'prod', + runtimeType: 'docker_host', + status: 'healthy', + agentId: 'agent-c', + targetCount: 1, + lastSeenAt: '2026-03-31T09:45:00Z', + probeStatus: 'not_installed', + probeType: null, + probeLastHeartbeat: null, + }, + ], + }); + httpMock.expectOne((req) => req.url === '/api/v2/topology/agents' && req.params.get('environment') === 'prod').flush({ + items: [ + { agentId: 'agent-a', agentName: 'agent-a', regionId: 'eu-west', environmentId: 'prod', status: 'active', capabilities: ['docker_host'], assignedTargetCount: 1, lastHeartbeatAt: '2026-03-31T10:00:00Z' }, + { agentId: 'agent-b', agentName: 'agent-b', regionId: 'eu-west', environmentId: 'prod', status: 'active', capabilities: ['docker_host'], assignedTargetCount: 1, lastHeartbeatAt: '2026-03-31T09:55:00Z' }, + { agentId: 'agent-c', agentName: 'agent-c', regionId: 'eu-west', environmentId: 'prod', status: 'active', capabilities: ['docker_host'], assignedTargetCount: 1, lastHeartbeatAt: '2026-03-31T09:45:00Z' }, + ], + }); + httpMock.expectOne((req) => req.url === '/api/v2/releases/activity' && req.params.get('environment') === 'prod').flush({ items: [] }); + httpMock.expectOne((req) => req.url === '/api/v2/security/findings' && req.params.get('environment') === 'prod').flush({ items: [] }); + httpMock.expectOne((req) => req.url === '/api/v2/evidence/packs' && req.params.get('environment') === 'prod').flush({ items: [] }); + httpMock.expectOne('/api/v1/environments/prod/readiness').flush({ items: [] }); + httpMock.expectOne((req) => req.url === '/api/v2/topology/promotion-paths' && req.params.get('environment') === 'prod').flush({ items: [] }); + httpMock.expectOne((req) => req.url === '/api/v2/topology/environments' && !req.params.has('environment')).flush({ + items: [ + { + environmentId: 'prod', + regionId: 'eu-west', + environmentType: 'production', + displayName: 'Production', + sortOrder: 1, + targetCount: 3, + hostCount: 3, + agentCount: 3, + promotionPathCount: 0, + workflowCount: 0, + lastSyncAt: '2026-03-31T10:00:00Z', + }, + ], + }); + + fixture.detectChanges(); + + component.activeTab.set('targets'); + fixture.detectChanges(); + + let text = fixture.nativeElement.textContent as string; + expect(text).toContain('Runtime'); + expect(text).toContain('Verified'); + expect(text).toContain('Drift'); + expect(text).toContain('Not monitored'); + + component.activeTab.set('drift'); + fixture.detectChanges(); + + text = fixture.nativeElement.textContent as string; + expect(text).toContain('Runtime Verification'); + expect(text).toContain('1 verified'); + expect(text).toContain('1 drift'); + expect(text).toContain('1 not monitored'); + expect(text).toContain('Expected Release'); + expect(text).toContain('Observed Release'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/testing/topology-host-detail-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/topology-host-detail-page.component.spec.ts new file mode 100644 index 000000000..eae910ee7 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/testing/topology-host-detail-page.component.spec.ts @@ -0,0 +1,108 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; + +import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { TopologyHostDetailPageComponent } from '../../features/topology/topology-host-detail-page.component'; + +describe('TopologyHostDetailPageComponent', () => { + let httpMock: HttpTestingController; + + beforeEach(() => { + const paramMap = convertToParamMap({ hostId: 'host-alpha' }); + + TestBed.configureTestingModule({ + imports: [TopologyHostDetailPageComponent], + providers: [ + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { + provide: ActivatedRoute, + useValue: { + paramMap: { + subscribe: (fn: (value: ReturnType) => void) => { + fn(paramMap); + return { unsubscribe() {} }; + }, + }, + }, + }, + { + provide: PlatformContextStore, + useValue: { + initialize: () => undefined, + selectedRegions: () => [], + selectedEnvironments: () => [], + regionSummary: () => 'All regions', + environmentSummary: () => 'All environments', + }, + }, + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('renders install guidance when a host has no runtime probe heartbeat', () => { + const fixture = TestBed.createComponent(TopologyHostDetailPageComponent); + + const hostsReq = httpMock.expectOne((req) => req.url === '/api/v2/topology/hosts'); + const targetsReq = httpMock.expectOne((req) => req.url === '/api/v2/topology/targets'); + + hostsReq.flush({ + items: [ + { + hostId: 'host-alpha', + hostName: 'alpha.stella.local', + regionId: 'eu-west', + environmentId: 'prod', + runtimeType: 'docker_host', + status: 'healthy', + agentId: 'agent-alpha', + targetCount: 1, + lastSeenAt: '2026-03-31T09:55:00Z', + probeStatus: 'not_installed', + probeType: null, + probeLastHeartbeat: null, + }, + ], + }); + + targetsReq.flush({ + items: [ + { + targetId: 'target-alpha', + name: 'api-alpha', + regionId: 'eu-west', + environmentId: 'prod', + hostId: 'host-alpha', + agentId: 'agent-alpha', + targetType: 'docker_host', + healthStatus: 'healthy', + componentVersionId: 'cmp-alpha', + imageDigest: 'sha256:alpha', + releaseId: 'rel-1', + releaseVersionId: 'rel-a', + lastSyncAt: '2026-03-31T10:00:00Z', + }, + ], + }); + + fixture.detectChanges(); + + const text = fixture.nativeElement.textContent as string; + expect(text).toContain('Runtime Probe'); + expect(text).toContain('No runtime probe heartbeat is currently bound to this host.'); + expect(text).toContain('Connection Profile'); + expect(text).toContain('Mapped Targets'); + expect(text).toContain('Recent Activity'); + expect(text).toContain('STELLA_HOST=alpha.stella.local'); + expect(fixture.nativeElement.querySelector('app-copy-to-clipboard')).toBeTruthy(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/testing/topology-hosts-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/topology-hosts-page.component.spec.ts new file mode 100644 index 000000000..62b240b03 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/testing/topology-hosts-page.component.spec.ts @@ -0,0 +1,147 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; + +import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { TopologyHostsPageComponent } from '../../features/topology/topology-hosts-page.component'; + +describe('TopologyHostsPageComponent', () => { + const queryParamMap$ = new BehaviorSubject(convertToParamMap({})); + + let httpMock: HttpTestingController; + + beforeEach(() => { + queryParamMap$.next(convertToParamMap({})); + + TestBed.configureTestingModule({ + imports: [TopologyHostsPageComponent], + providers: [ + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { + provide: ActivatedRoute, + useValue: { + queryParamMap: queryParamMap$.asObservable(), + }, + }, + { + provide: PlatformContextStore, + useValue: { + initialize: () => undefined, + contextVersion: () => 0, + selectedRegions: () => [], + selectedEnvironments: () => [], + regionSummary: () => 'All regions', + environmentSummary: () => 'All environments', + }, + }, + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('renders runtime probe state and filters down to unmonitored hosts', () => { + const fixture = TestBed.createComponent(TopologyHostsPageComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + const hostsReq = httpMock.expectOne((req) => req.url === '/api/v2/topology/hosts'); + const targetsReq = httpMock.expectOne((req) => req.url === '/api/v2/topology/targets'); + + hostsReq.flush({ + items: [ + { + hostId: 'host-alpha', + hostName: 'alpha.stella.local', + regionId: 'eu-west', + environmentId: 'prod', + runtimeType: 'docker_host', + status: 'healthy', + agentId: 'agent-alpha', + targetCount: 1, + lastSeenAt: '2026-03-31T09:55:00Z', + probeStatus: 'active', + probeType: 'ebpf', + probeLastHeartbeat: '2026-03-31T10:00:00Z', + }, + { + hostId: 'host-beta', + hostName: 'beta.stella.local', + regionId: 'eu-west', + environmentId: 'prod', + runtimeType: 'docker_host', + status: 'degraded', + agentId: 'agent-beta', + targetCount: 1, + lastSeenAt: '2026-03-31T09:40:00Z', + probeStatus: 'not_installed', + probeType: null, + probeLastHeartbeat: null, + }, + ], + }); + + targetsReq.flush({ + items: [ + { + targetId: 'target-alpha', + name: 'api-alpha', + regionId: 'eu-west', + environmentId: 'prod', + hostId: 'host-alpha', + agentId: 'agent-alpha', + targetType: 'docker_host', + healthStatus: 'healthy', + componentVersionId: 'cmp-alpha', + imageDigest: 'sha256:alpha', + releaseId: 'rel-1', + releaseVersionId: 'rel-a', + lastSyncAt: '2026-03-31T10:00:00Z', + }, + { + targetId: 'target-beta', + name: 'api-beta', + regionId: 'eu-west', + environmentId: 'prod', + hostId: 'host-beta', + agentId: 'agent-beta', + targetType: 'docker_host', + healthStatus: 'degraded', + componentVersionId: 'cmp-beta', + imageDigest: 'sha256:beta', + releaseId: 'rel-1', + releaseVersionId: 'rel-a', + lastSyncAt: '2026-03-31T09:45:00Z', + }, + ], + }); + + fixture.detectChanges(); + + const text = fixture.nativeElement.textContent as string; + expect(text).toContain('Runtime Probe'); + expect(text).toContain('Last Seen'); + expect(text).toContain('Active'); + expect(text).toContain('Not installed'); + expect(text).toContain('eBPF'); + expect(fixture.nativeElement.querySelector('.cell-host__link')).toBeTruthy(); + + const probeSelect = fixture.nativeElement.querySelector('#hosts-probe') as HTMLSelectElement; + probeSelect.value = 'unmonitored'; + probeSelect.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect(component.filteredHosts().map((item) => item.hostId)).toEqual(['host-beta']); + const tbodyText = fixture.nativeElement.querySelector('tbody')?.textContent ?? ''; + expect(tbodyText).toContain('beta.stella.local'); + expect(tbodyText).not.toContain('alpha.stella.local'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts index cf5ebc7d0..813de2e32 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts @@ -1,5 +1,5 @@ import { HttpClient, HttpParams } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { catchError, forkJoin, of, take } from 'rxjs'; @@ -25,11 +25,20 @@ import { TopologyPromotionPath, TopologyTarget, } from './topology.models'; +import { + buildRuntimeVerificationSummary, + dominantReleaseVersion, + runtimeVerificationTone, + shortDigest, + shortId, + type RuntimeVerificationStatus, +} from './topology-runtime.helpers'; import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component'; import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component'; import { StatGroupComponent } from '../../shared/components/stat-card/stat-card.component'; import { RelativeTimePipe, DurationPipe } from '../../shared/pipes/format.pipes'; +import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service'; type EnvironmentTab = 'overview' | 'targets' | 'readiness' | 'deployments' | 'agents' | 'security' | 'evidence' | 'drift' | 'data-quality'; @@ -70,9 +79,9 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { template: `
- +
- ← Environments + <- Environments

{{ environmentLabel() }}

@@ -80,12 +89,12 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { @if (currentRelease()) { {{ currentRelease() }} } @if (isFrozen()) { }
-

{{ regionLabel() }} · {{ environmentTypeLabel() }}

+

{{ regionLabel() }} | {{ environmentTypeLabel() }}

@if (promotionLine()) {

{{ promotionLine() }}

}
- - Deploy + + Request Promotion
@@ -104,7 +113,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { } @else { @switch (activeTab()) { - + @case ('overview') {
@@ -135,7 +144,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {

Readiness Snapshot

- View full → + View full ->
{{ readyTargetsCnt() }} pass @@ -170,30 +179,67 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
} - + @case ('targets') { -
- - - - @for (t of targetRows(); track t.targetId) { - - - - - - - - - } @empty { - - } - -
TargetTypeHostAgentStatusLast Sync
{{ t.name }}{{ t.targetType }}{{ hostName(t.hostId) }}{{ agentName(t.agentId) }}{{ t.lastSyncAt | relativeTime }}
No targets in this environment.
-
+ + + + + + + + @if (targetRows().length === 0) { +
+
+ +
+

No targets are mapped to this environment yet

+

+ Targets appear after Stella has discovered runtime hosts and associated workloads with this environment. + Until that happens, readiness checks, drift detection, and deployment health all stay empty here. +

+
+ +
+
+ } @else { +
+

+ Runtime state combines host probe heartbeat plus the dominant deployed release version for this environment. Missing probe data renders as Not monitored. +

+ + + + @for (row of runtimeVerificationRows(); track row.target.targetId) { + + + + + + + + + + } + +
TargetTypeHostAgentStatusRuntimeLast Sync
{{ row.target.name }}{{ row.target.targetType }}{{ hostName(row.target.hostId) }}{{ agentName(row.target.agentId) }} +
+ + {{ row.summary.probeTypeLabel }} +
+
{{ row.target.lastSyncAt | relativeTime }}
+
+ } } - + @case ('readiness') { @@ -201,49 +247,77 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { -
-
-

Gate Status

- -
-
- - - - - @for (gn of gateNames; track gn) { } - - - - - - @for (rpt of readinessReports(); track rpt.targetId) { - - - @for (gn of gateNames; track gn) { - - } - - - - } @empty { - + @if (readinessReports().length === 0) { +
+
+ +
+

No readiness checks have been captured yet

+

+ Readiness records show whether agents are bound, registries are reachable, and connectivity gates are passing for each target. + @if (targetRows().length === 0) { + Add targets to this environment first, then Stella can validate promotion readiness. + } @else { + Run validation once to populate the gate matrix before reviewing or promoting this environment. + } +

+
+
+ @if (targetRows().length === 0) { + Review targets + Open integrations + } @else { + + } -
-
Target{{ fmtGate(gn) }}ReadyActions
{{ targetName(rpt.targetId) }} - - - -
No readiness data. Run validation to check targets.
-
-
+
+
+ + } @else { +
+
+

Gate Status

+ +
+
+ + + + + @for (gn of gateNames; track gn) { } + + + + + + @for (rpt of readinessReports(); track rpt.targetId) { + + + @for (gn of gateNames; track gn) { + + } + + + + } + +
Target{{ fmtGate(gn) }}ReadyActions
{{ targetName(rpt.targetId) }} + + + +
+
+
+ } } - + @case ('deployments') {
@@ -253,9 +327,9 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { - + - + } @empty { @@ -265,29 +339,46 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { } - + @case ('agents') { -
-
{{ run.releaseName }} {{ run.durationMs ? (run.durationMs | duration) : '—' }}{{ run.durationMs ? (run.durationMs | duration) : '-' }} {{ run.occurredAt | relativeTime }}View →View ->
No deployment runs in this scope.
- - - @for (a of agentRows(); track a.agentId) { - - - - - - - - } @empty { - - } - -
AgentStatusCapabilitiesTargetsHeartbeat
{{ a.agentName }}{{ a.capabilities.join(', ') || '—' }}{{ a.assignedTargetCount }}{{ a.lastHeartbeatAt | relativeTime }}
No agents in this environment.
-
+ @if (agentRows().length === 0) { +
+
+ +
+

No agents are reporting for this environment

+

+ Agents are what let Stella validate readiness and execute deployments on real hosts. + Until an agent is connected here, this environment can describe topology but cannot perform release work. +

+
+ +
+
+ } @else { +
+ + + + @for (a of agentRows(); track a.agentId) { + + + + + + + + } + +
AgentStatusCapabilitiesTargetsHeartbeat
{{ a.agentName }}{{ a.capabilities.join(', ') || '-' }}{{ a.assignedTargetCount }}{{ a.lastHeartbeatAt | relativeTime }}
+
+ } } - + @case ('security') {
@@ -297,14 +388,14 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { - + - + } @empty { @@ -314,7 +405,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { } - + @case ('evidence') { @if (capsuleRows().length === 0) {

No decision capsules in this scope.

@@ -348,7 +439,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { } } - + @case ('drift') {
@if (driftDetected()) { @@ -365,9 +456,74 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { }
+ +
+
+
+

Runtime Verification

+

+ Probe-backed runtime status is shown here. True running-vs-deployed container digest comparison is not available until the backend exposes running inventory evidence. +

+
+ +
+ +
+ {{ runtimeVerifiedCount() }} verified + {{ runtimeDriftCount() }} drift + {{ runtimeOfflineCount() }} offline + {{ runtimeUnmonitoredCount() }} not monitored +
+ + @if (runtimeSectionExpanded()) { +
+
{{ f.cveId }} {{ f.cvss ?? '—' }}{{ f.cvss ?? '-' }} @if (f.reachable != null) { - } @else { — } + } @else { - } {{ f.effectiveDisposition }}View →View ->
No active findings in this scope.
+ + + + + + + + + + + + + @for (row of runtimeVerificationRows(); track row.target.targetId) { + + + + + + + + + + } @empty { + + } + +
TargetHostProbeExpected ReleaseObserved ReleaseImage DigestRuntime
{{ row.target.name }}{{ hostName(row.target.hostId) }}{{ row.summary.probeTypeLabel }}{{ shortId(row.summary.expectedReleaseVersion) }}{{ shortId(row.summary.observedReleaseVersion) }}{{ shortDigest(row.target.imageDigest) }} +
+ + @if (row.summary.lastVerifiedAt) { + {{ row.summary.lastVerifiedAt | relativeTime }} + } +
+
No target runtime verification data in this scope.
+ + } +
} - + @case ('data-quality') { @@ -383,7 +539,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { styles: [` .env-detail { display: grid; gap: 0.75rem; } - /* ── Header ── */ + /* Header */ .hdr { display: grid; gap: 0.25rem; padding: 0.6rem 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); } .hdr__back { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); text-decoration: none; } .hdr__back:hover { text-decoration: underline; } @@ -395,7 +551,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { .hdr__actions { display: flex; gap: 0.35rem; align-items: center; justify-content: flex-end; } .chip--release { font-size: 0.66rem; padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); background: var(--color-surface-secondary); color: var(--color-text-secondary); font-family: var(--font-mono, monospace); } - /* ── Buttons ── */ + /* Buttons */ .btn { padding: 0.3rem 0.65rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.74rem; font-weight: 500; border: none; text-decoration: none; display: inline-flex; align-items: center; gap: 0.2rem; transition: background 150ms ease; } .btn--sm { padding: 0.28rem 0.6rem; } .btn--xs { padding: 0.2rem 0.4rem; font-size: 0.68rem; } @@ -409,33 +565,54 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { .link:hover { text-decoration: underline; } .mono { font-family: var(--font-mono, monospace); font-size: 0.72rem; } - /* ── Panels ── */ + /* Panels */ .panel { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.7rem; display: grid; gap: 0.4rem; } + .panel--empty { padding: 0.85rem; } .panel h2 { margin: 0; font-size: 0.88rem; font-weight: 600; } .panel__hdr { display: flex; justify-content: space-between; align-items: center; } .panel__link { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); cursor: pointer; text-decoration: none; } .panel__link:hover { text-decoration: underline; } .panel__ok { margin: 0; color: var(--color-status-success, #22c55e); font-size: 0.76rem; } + .empty-panel { display: grid; gap: 0.75rem; align-items: start; } + .empty-panel__icon { + width: 2rem; + height: 2rem; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--color-brand-soft, rgba(59,130,246,0.12)) 75%, transparent); + color: var(--color-text-link, var(--color-brand, #3b82f6)); + font-size: 1rem; + font-weight: 700; + } + .empty-panel__body { display: grid; gap: 0.35rem; } + .empty-panel__body h3 { margin: 0; font-size: 0.9rem; font-weight: 600; } + .empty-panel__body p { margin: 0; color: var(--color-text-secondary); font-size: 0.76rem; line-height: 1.5; } + .empty-panel__actions { display: flex; flex-wrap: wrap; gap: 0.45rem; } .banner { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.5rem 0.65rem; font-size: 0.78rem; } .banner--error { color: var(--color-status-error-text, #ef4444); background: var(--color-status-error-bg, rgba(239,68,68,0.06)); } .muted { color: var(--color-text-muted); font-size: 0.74rem; } + .runtime-note { margin: 0; color: var(--color-text-secondary); font-size: 0.74rem; line-height: 1.45; } + .runtime-cell { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 0.15rem; } + .runtime-caption { color: var(--color-text-muted); font-size: 0.64rem; } - /* ── Overview 2-column layout ── */ + /* Overview 2-column layout */ .overview-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 0.65rem; } .overview-main { display: grid; gap: 0.65rem; } .overview-side { display: grid; gap: 0.65rem; align-content: start; } - /* ── Blocker list ── */ + /* Blocker list */ .blocker-list { display: grid; gap: 0.3rem; } .blocker-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.76rem; } - /* ── Readiness mini ── */ + /* Readiness mini */ .readiness-mini { display: flex; gap: 0.75rem; font-size: 0.78rem; font-weight: 600; } .rm--ok { color: var(--color-status-success, #22c55e); } .rm--fail { color: var(--color-status-error, #ef4444); } .rm--pend { color: var(--color-status-warning, #f59e0b); } - /* ── Health circle ── */ + /* Health circle */ .health-circle-panel { display: flex; justify-content: center; padding: 1rem; } .health-circle { width: 80px; height: 80px; border-radius: 50%; @@ -448,18 +625,18 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { .health-circle__num { font-size: 1.1rem; font-weight: 700; line-height: 1; } .health-circle__label { font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; } - /* ── Quick stats ── */ + /* Quick stats */ .quick-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.35rem; text-align: center; } .qs { display: flex; flex-direction: column; align-items: center; } .qs__v { font-size: 1rem; font-weight: 700; color: var(--color-text-heading); } .qs__l { font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; } - /* ── Gate grid ── */ + /* Gate grid */ .gg-wrap { overflow-x: auto; } .th-gate, .td-gate { text-align: center; font-size: 0.68rem; } .th-gate { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; } - /* ── Evidence grid ── */ + /* Evidence grid */ .evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.5rem; } .ev-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.55rem; display: grid; gap: 0.3rem; } .ev-card__hdr { display: flex; justify-content: space-between; align-items: center; } @@ -470,12 +647,30 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { .ev-card__date { font-size: 0.66rem; color: var(--color-text-muted); } .ev-card__foot { display: flex; gap: 0.3rem; } - /* ── Drift ── */ + /* Drift */ .drift-alert { padding: 0.5rem; background: var(--color-status-warning-bg, rgba(245,158,11,0.08)); border-radius: var(--radius-sm); } .drift-alert__msg { display: flex; align-items: flex-start; gap: 0.5rem; } .drift-alert__msg p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); } .drift-ok { display: flex; align-items: center; gap: 0.5rem; } .drift-ok p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); } + .runtime-summary { display: flex; flex-wrap: wrap; gap: 0.35rem; } + .runtime-summary__item { + display: inline-flex; + align-items: center; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); + padding: 0.12rem 0.45rem; + font-size: 0.68rem; + font-weight: 600; + } + .runtime-summary__item--verified { background: var(--color-status-success-bg, rgba(34,197,94,0.08)); color: var(--color-status-success-text, #15803d); } + .runtime-summary__item--drift { background: var(--color-status-warning-bg, rgba(245,158,11,0.1)); color: var(--color-status-warning-text, #b45309); } + .runtime-summary__item--offline { background: var(--color-status-error-bg, rgba(239,68,68,0.08)); color: var(--color-status-error-text, #b91c1c); } + .runtime-summary__item--unmonitored { background: var(--color-surface-secondary); color: var(--color-text-secondary); } + .runtime-matrix-wrap { overflow-x: auto; } + .runtime-row--drift { background: var(--color-status-warning-bg, rgba(245,158,11,0.1)); } + .runtime-row--offline { background: var(--color-status-error-bg, rgba(239,68,68,0.08)); } + .runtime-row--unmonitored { background: var(--color-surface-secondary); } @media (max-width: 1024px) { .overview-layout { grid-template-columns: 1fr; } @@ -488,6 +683,8 @@ export class TopologyEnvironmentDetailPageComponent { private readonly topologySetup = inject(TopologySetupClient); private readonly http = inject(HttpClient); private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + private readonly helperCtx = inject(StellaHelperContextService); readonly context = inject(PlatformContextStore); readonly gateNames = GATE_NAMES; @@ -514,20 +711,53 @@ export class TopologyEnvironmentDetailPageComponent { readonly currentRelease = signal(null); readonly validating = signal>(new Set()); readonly validatingAll = signal(false); + readonly runtimeSectionExpanded = signal(true); - // ── Lookup maps ── + // Lookup maps + readonly hostMap = computed(() => new Map(this.hostRows().map(h => [h.hostId, h]))); readonly hostNameMap = computed(() => new Map(this.hostRows().map(h => [h.hostId, h.hostName]))); readonly agentNameMap = computed(() => new Map(this.agentRows().map(a => [a.agentId, a.agentName]))); readonly targetNameMap = computed(() => new Map(this.targetRows().map(t => [t.targetId, t.name]))); readonly envNameMap = computed(() => new Map(this.allEnvironments().map(e => [e.environmentId, e.displayName]))); - // ── Health computeds ── + // Health computeds readonly healthyTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'healthy').length); readonly degradedTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'degraded').length); readonly unhealthyTargets = computed(() => this.targetRows().filter(t => { const s = t.healthStatus.trim().toLowerCase(); return s === 'unhealthy' || s === 'offline' || s === 'unknown'; }).length); + readonly unknownHealthTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'unknown').length); readonly blockingFindings = computed(() => this.findingRows().filter(f => f.effectiveDisposition.trim().toLowerCase() === 'action_required').length); readonly staleCapsules = computed(() => this.capsuleRows().filter(c => c.status.trim().toLowerCase().includes('stale')).length); readonly degradedAgents = computed(() => this.agentRows().filter(a => a.status.trim().toLowerCase() !== 'active').length); + readonly helperContexts = computed(() => { + const contexts: string[] = []; + if (this.blockingFindings() > 0) { + contexts.push('critical-open'); + } + if (this.failingTargetsCnt() > 0) { + contexts.push('gate-blocked'); + } + if (this.unknownHealthTargets() > 0) { + contexts.push('health-unknown'); + } + if (this.degradedAgents() > 0) { + contexts.push('agents-degraded'); + } + if (!this.loading() && this.agentRows().length === 0) { + contexts.push('agents-none'); + } + if (!this.loading()) { + if (this.activeTab() === 'targets' && this.targetRows().length === 0) { + contexts.push('empty-table'); + } + if (this.activeTab() === 'readiness' && this.readinessReports().length === 0) { + contexts.push('empty-table'); + } + if (this.activeTab() === 'agents' && this.agentRows().length === 0) { + contexts.push('empty-table'); + } + } + return contexts; + }); readonly deployHealth = computed(() => { if (this.unhealthyTargets() > 0) return 'UNHEALTHY'; @@ -540,12 +770,12 @@ export class TopologyEnvironmentDetailPageComponent { return h === 'HEALTHY' ? 'success' : h === 'DEGRADED' ? 'warning' : 'error'; }); - // ── Readiness computeds ── + // Readiness computeds readonly readyTargetsCnt = computed(() => this.readinessReports().filter(r => r.isReady).length); readonly failingTargetsCnt = computed(() => this.readinessReports().filter(r => !r.isReady && r.gates.some(g => g.status === 'fail')).length); readonly pendingTargetsCnt = computed(() => this.readinessReports().filter(r => !r.isReady && r.gates.some(g => g.status === 'pending') && !r.gates.some(g => g.status === 'fail')).length); - // ── Drift ── + // Drift readonly driftDetected = computed(() => { const targets = this.targetRows(); if (targets.length < 2) return false; @@ -562,7 +792,30 @@ export class TopologyEnvironmentDetailPageComponent { return targets.length - maxCount; }); - // ── Promotion context ── + // Promotion context + readonly expectedReleaseVersion = computed(() => dominantReleaseVersion(this.targetRows())); + readonly runtimeVerificationRows = computed(() => + this.targetRows().map((target) => ({ + target, + summary: buildRuntimeVerificationSummary( + target, + this.hostMap().get(target.hostId), + this.expectedReleaseVersion(), + ), + })), + ); + readonly runtimeVerifiedCount = computed(() => + this.runtimeVerificationRows().filter(row => row.summary.status === 'verified').length, + ); + readonly runtimeDriftCount = computed(() => + this.runtimeVerificationRows().filter(row => row.summary.status === 'drift').length, + ); + readonly runtimeOfflineCount = computed(() => + this.runtimeVerificationRows().filter(row => row.summary.status === 'offline').length, + ); + readonly runtimeUnmonitoredCount = computed(() => + this.runtimeVerificationRows().filter(row => row.summary.status === 'not_monitored').length, + ); readonly promotionLine = computed(() => { const envId = this.environmentId(); const paths = this.promotionPaths(); @@ -571,13 +824,13 @@ export class TopologyEnvironmentDetailPageComponent { const upstream = paths.filter(p => p.targetEnvironmentId === envId).map(p => names.get(p.sourceEnvironmentId) ?? p.sourceEnvironmentId); const downstream = paths.filter(p => p.sourceEnvironmentId === envId).map(p => names.get(p.targetEnvironmentId) ?? p.targetEnvironmentId); const parts: string[] = []; - if (upstream.length) parts.push(upstream.join(', ') + ' →'); + if (upstream.length) parts.push(upstream.join(', ') + ' ->'); parts.push(`[${this.environmentLabel()}]`); - if (downstream.length) parts.push('→ ' + downstream.join(', ')); + if (downstream.length) parts.push('-> ' + downstream.join(', ')); return parts.join(' '); }); - // ── Blockers ── + // Blockers readonly blockers = computed(() => { const items: { severity: 'error' | 'warning'; text: string }[] = []; if (this.unhealthyTargets() > 0) items.push({ severity: 'error', text: `${this.unhealthyTargets()} unhealthy target(s) require runtime remediation.` }); @@ -588,7 +841,7 @@ export class TopologyEnvironmentDetailPageComponent { return items; }); - // ── Tabs (dynamic status dots + badges) ── + // Tabs (dynamic status dots + badges) readonly tabDefs = computed((): StellaPageTab[] => [ { id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', status: this.deployHealthStatus() === 'success' ? 'ok' : this.deployHealthStatus() === 'warning' ? 'warn' : 'error' }, { id: 'targets', label: 'Targets', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01', badge: this.targetRows().length || undefined }, @@ -607,13 +860,16 @@ export class TopologyEnvironmentDetailPageComponent { { label: 'Runs', route: '/releases/deployments', description: 'Deployment runs' }, ]); - // ── Helpers ── + // Helpers hostName(id: string): string { return this.hostNameMap().get(id) ?? id.substring(0, 8) + '...'; } agentName(id: string): string { return this.agentNameMap().get(id) ?? id.substring(0, 8) + '...'; } targetName(id: string): string { return this.targetNameMap().get(id) ?? id.substring(0, 12); } healthToStatus(h: string): 'success' | 'warning' | 'error' | 'neutral' { return healthToStatus(h); } severityStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { return severityToStatus(s); } fmtGate(g: string): string { return fmtGateName(g); } + runtimeStatusTone(status: RuntimeVerificationStatus): 'success' | 'warning' | 'error' | 'neutral' { return runtimeVerificationTone(status); } + shortId(value: string | null | undefined): string { return shortId(value); } + shortDigest(value: string | null | undefined): string { return shortDigest(value); } gateStatus(rpt: ReadinessReport, gateName: string): 'success' | 'error' | 'warning' | 'neutral' { const gate = rpt.gates.find(x => x.gateName === gateName); @@ -622,8 +878,8 @@ export class TopologyEnvironmentDetailPageComponent { gateLabel(rpt: ReadinessReport, gateName: string): string { const gate = rpt.gates.find(x => x.gateName === gateName); - if (!gate) return '—'; - switch (gate.status) { case 'pass': return '✓'; case 'fail': return '✗'; case 'pending': return '●'; default: return '—'; } + if (!gate) return '-'; + switch (gate.status) { case 'pass': return 'OK'; case 'fail': return 'Fail'; case 'pending': return 'Pending'; default: return '-'; } } runStatusType(status: string): 'success' | 'warning' | 'error' | 'neutral' { @@ -634,10 +890,14 @@ export class TopologyEnvironmentDetailPageComponent { return 'neutral'; } - // ── Lifecycle ── + // Lifecycle constructor() { this.context.initialize(); + effect(() => { + this.helperCtx.setScope('topology-environment-detail', this.helperContexts()); + }, { allowSignalWrites: true }); + this.destroyRef.onDestroy(() => this.helperCtx.clearScope('topology-environment-detail')); this.route.paramMap.subscribe(params => { const id = params.get('environmentId') ?? ''; this.environmentId.set(id); @@ -697,8 +957,14 @@ export class TopologyEnvironmentDetailPageComponent { let best = ''; let bestCount = 0; for (const [v, c] of counts) if (c > bestCount) { best = v; bestCount = c; } this.currentRelease.set(best.substring(0, 12)); + } else { + this.currentRelease.set(null); } + this.runtimeSectionExpanded.set( + this.driftDetected() || this.runtimeOfflineCount() > 0 || this.runtimeUnmonitoredCount() > 0, + ); + this.loading.set(false); }, error: (err: unknown) => { @@ -708,7 +974,7 @@ export class TopologyEnvironmentDetailPageComponent { }); } - // ── Actions ── + // Actions validateTarget(targetId: string): void { const s = new Set(this.validating()); s.add(targetId); this.validating.set(s); diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-host-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-host-detail-page.component.ts index 077ccc636..8c83a3c6a 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-host-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-host-detail-page.component.ts @@ -1,22 +1,698 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { catchError, forkJoin, of, take } from 'rxjs'; + +import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { RelativeTimePipe } from '../../shared/pipes/format.pipes'; +import { CopyToClipboardComponent } from '../../shared/ui/copy-to-clipboard/copy-to-clipboard.component'; +import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component'; +import { TopologyDataService } from './topology-data.service'; +import { TopologyHost, TopologyTarget } from './topology.models'; +import { + dominantReleaseVersion, + hasRuntimeProbe, + HostConnectionProfile, + inferConnectionProfile, + inferProbeRecommendation, + normalizeProbeStatus, + probeStatusLabel, + probeStatusTone, + probeTypeLabel, + shortDigest, + shortId, +} from './topology-runtime.helpers'; + +interface HostActivityRow { + targetId: string; + targetName: string; + lastSyncAt: string | null; + releaseVersionId: string | null; + imageDigest: string | null; + drifted: boolean; +} @Component({ selector: 'app-topology-host-detail-page', standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, RelativeTimePipe, CopyToClipboardComponent, StatusBadgeComponent], template: ` -
-
-

Host {{ hostId }}

-

Inventory, connectivity tests, mapped targets, and agent diagnostics.

+
+
+ + + @if (host()) { +
+
+
+

{{ host()!.hostName }}

+ + +
+

{{ host()!.regionId }} / {{ host()!.environmentId }} / {{ host()!.runtimeType }}

+
+ +
+ Targets {{ hostTargets().length }} + Probe {{ probeType() }} + Last seen {{ lastSeenLabel() }} +
+
+ } @else { +
+
+

Host {{ hostId() }}

+

Waiting for topology data.

+
+
+ }
+ + @if (error()) { + + } + + @if (loading()) { +
Loading host detail...
+ } @else if (!host()) { +
+

No topology host matched {{ hostId() }} in the current scope.

+
+ } @else { +
+
+

Host Overview

+
+ Host ID + {{ host()!.hostId }} + + Region + {{ host()!.regionId }} + + Environment + {{ host()!.environmentId }} + + Runtime + {{ host()!.runtimeType }} + + Agent + {{ host()!.agentId || 'Not assigned' }} + + Last seen + {{ host()!.lastSeenAt ? (host()!.lastSeenAt | relativeTime) : 'No host heartbeat yet' }} +
+
+ +
+

Connection Profile

+
+ Connection + {{ connectionProfile().connectionLabel }} + + Endpoint + {{ connectionProfile().endpointLabel }} + + Port / Profile + {{ connectionProfile().portLabel }} + + Credential ref + Not exposed by topology API + + Connection test + Use mapped target validation until host-level test endpoints exist. +
+ +

{{ connectionProfile().summary }}

+ + +
+ +
+
+
+

Mapped Targets

+

{{ hostTargets().length }} target(s) currently mapped to this host.

+
+
+ + + + + + + + + + + + + + @for (target of hostTargets(); track target.targetId) { + + + + + + + + + } @empty { + + + + } + +
TargetTypeStatusReleaseImage Digest
{{ target.name }}{{ target.targetType }} + + {{ shortId(target.releaseVersionId) }}{{ shortDigest(target.imageDigest) }}Inspect
No mapped targets for this host.
+
+ +
+
+
+

Runtime Probe

+

{{ probePanelSummary() }}

+
+
+ + @if (!hasProbe()) { +
+

No runtime probe heartbeat is currently bound to this host.

+
+ Recommended probe + {{ probeType() }} + + Command shell + {{ connectionProfile().shell }} +
+ + + +
+ {{ installCommand() }} + +
+
+ } @else { +
+ Probe status + + + + + Probe type + {{ probeType() }} + + Last heartbeat + {{ probeLastHeartbeatLabel() }} + + Coverage proxy + {{ hostTargets().length }} mapped target(s) +
+ +

+ Exact probe health metrics such as CPU overhead, buffer utilization, and events per second are not yet exposed by the topology read model. +

+ } +
+ +
+
+
+

Recent Activity

+

Latest topology sync rows derived from mapped targets.

+
+
+ + + + + + + + + + + + + @for (row of recentActivity(); track row.targetId) { + + + + + + + + } @empty { + + + + } + +
TargetLast SyncReleaseDigestDrift
{{ row.targetName }}{{ row.lastSyncAt ? (row.lastSyncAt | relativeTime) : 'Never' }}{{ shortId(row.releaseVersionId) }}{{ shortDigest(row.imageDigest) }} + +
No target activity for this host yet.
+
+
+ }
`, + styles: [` + .host-detail { + display: grid; + gap: 0.75rem; + } + + .hero { + display: grid; + gap: 0.45rem; + padding: 0.8rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + } + + .hero__nav { + display: flex; + justify-content: flex-start; + } + + .back-link { + color: var(--color-text-link); + text-decoration: none; + font-size: 0.74rem; + font-weight: 500; + } + + .back-link:hover { + text-decoration: underline; + } + + .hero__main { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: flex-start; + flex-wrap: wrap; + } + + .hero__title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + } + + .hero__title-row h1 { + margin: 0; + font-size: 1.2rem; + color: var(--color-text-heading); + } + + .hero__subtitle { + margin: 0.15rem 0 0; + color: var(--color-text-secondary); + font-size: 0.78rem; + } + + .hero__chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + } + + .chip { + 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.12rem 0.45rem; + white-space: nowrap; + } + + .banner { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + padding: 0.55rem 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); + } + + .loading, + .empty-state { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + padding: 1rem; + color: var(--color-text-secondary); + font-size: 0.8rem; + } + + .layout { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .card { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + padding: 0.75rem; + display: grid; + gap: 0.55rem; + align-content: start; + } + + .card--wide { + grid-column: 1 / -1; + } + + .card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + } + + .card h2 { + margin: 0; + font-size: 0.92rem; + color: var(--color-card-heading); + font-weight: 600; + } + + .card__header p { + margin: 0.15rem 0 0; + color: var(--color-text-secondary); + font-size: 0.74rem; + } + + .meta-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 0.7rem; + } + + .meta-grid__label { + color: var(--color-text-muted); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; + padding-top: 0.1rem; + } + + .meta-grid__value { + color: var(--color-text-secondary); + font-size: 0.76rem; + line-height: 1.45; + } + + .summary { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.74rem; + line-height: 1.5; + } + + .actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + } + + .actions a, + .link { + color: var(--color-text-link); + text-decoration: none; + font-size: 0.73rem; + font-weight: 500; + } + + .actions a:hover, + .link:hover { + text-decoration: underline; + } + + .probe-empty { + display: grid; + gap: 0.65rem; + } + + .probe-empty p { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.76rem; + } + + .toggle { + display: flex; + gap: 0.45rem; + align-items: center; + color: var(--color-text-primary); + font-size: 0.74rem; + } + + .command-block { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.45rem; + align-items: start; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-secondary); + padding: 0.55rem; + } + + .command-block code, + .mono { + font-family: var(--font-mono, monospace); + font-size: 0.72rem; + word-break: break-all; + } + + .activity-row--drift { + background: var(--color-status-warning-bg, rgba(245, 158, 11, 0.1)); + } + + .muted { + color: var(--color-text-muted); + font-size: 0.74rem; + } + + table { + width: 100%; + } + + @media (max-width: 1080px) { + .layout { + grid-template-columns: 1fr; + } + + .card--wide { + grid-column: auto; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class TopologyHostDetailPageComponent { + private readonly topologyApi = inject(TopologyDataService); private readonly route = inject(ActivatedRoute); - readonly hostId = this.route.snapshot.paramMap.get('hostId') ?? 'unknown'; + + readonly context = inject(PlatformContextStore); + + readonly hostId = signal(''); + readonly loading = signal(true); + readonly error = signal(null); + readonly enableRuntimeVerification = signal(true); + + readonly hosts = signal([]); + readonly targets = signal([]); + + readonly host = computed(() => this.hosts().find((item) => item.hostId === this.hostId()) ?? null); + + readonly hostTargets = computed(() => { + const host = this.host(); + if (!host) { + return []; + } + + return this.targets() + .filter((item) => item.hostId === host.hostId) + .sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })); + }); + + readonly dominantHostRelease = computed(() => dominantReleaseVersion(this.hostTargets())); + + readonly recentActivity = computed(() => { + const expectedRelease = this.dominantHostRelease(); + + return this.hostTargets() + .map((target) => ({ + targetId: target.targetId, + targetName: target.name, + lastSyncAt: target.lastSyncAt, + releaseVersionId: target.releaseVersionId || null, + imageDigest: target.imageDigest || null, + drifted: + !!expectedRelease && + !!target.releaseVersionId && + target.releaseVersionId.trim().length > 0 && + target.releaseVersionId !== expectedRelease, + })) + .sort((a, b) => { + const aTime = a.lastSyncAt ? Date.parse(a.lastSyncAt) : 0; + const bTime = b.lastSyncAt ? Date.parse(b.lastSyncAt) : 0; + return bTime - aTime; + }) + .slice(0, 5); + }); + + readonly connectionProfile = computed(() => { + const host = this.host(); + if (!host) { + return inferConnectionProfile('-', 'docker_host'); + } + + return inferConnectionProfile(host.hostName, host.runtimeType); + }); + + readonly hasProbe = computed(() => hasRuntimeProbe(this.host())); + + readonly probeTone = computed(() => { + const host = this.host(); + return probeStatusTone(normalizeProbeStatus(host?.probeStatus)); + }); + + readonly probeLabel = computed(() => { + const host = this.host(); + return probeStatusLabel(normalizeProbeStatus(host?.probeStatus)); + }); + + readonly probeType = computed(() => { + const host = this.host(); + const fallbackProbe = inferProbeRecommendation(host?.runtimeType ?? this.connectionProfile().connectionLabel); + return probeTypeLabel(host?.probeType ?? fallbackProbe); + }); + + readonly probePanelSummary = computed(() => { + if (!this.hasProbe()) { + return 'Install guidance is shown because no probe heartbeat is currently attached to this host.'; + } + + return `${this.probeType()} probe state is ${this.probeLabel().toLowerCase()}.`; + }); + + readonly installCommand = computed(() => { + const host = this.host(); + const hostToken = host?.hostName ?? ''; + const includeVerificationFlag = this.enableRuntimeVerification(); + + if (this.connectionProfile().shell === 'powershell') { + const verificationSegment = includeVerificationFlag ? "$env:STELLA_RUNTIME_VERIFICATION='1'; " : ''; + return `$env:STELLA_HOST='${hostToken}'; $env:STELLA_TOKEN=''; ${verificationSegment}Invoke-WebRequest -UseBasicParsing https://stella-ops.local/api/v1/agents/install.ps1 | Invoke-Expression`; + } + + const verificationSegment = includeVerificationFlag ? ' STELLA_RUNTIME_VERIFICATION=1' : ''; + return `curl -sSL https://stella-ops.local/api/v1/agents/install.sh | STELLA_HOST=${hostToken} STELLA_TOKEN=${verificationSegment} bash`; + }); + + constructor() { + this.context.initialize(); + + this.route.paramMap.subscribe((params) => { + const hostId = params.get('hostId') ?? ''; + this.hostId.set(hostId); + if (hostId) { + this.load(); + } + }); + } + + shortId(value: string | null | undefined): string { + return shortId(value); + } + + shortDigest(value: string | null | undefined): string { + return shortDigest(value); + } + + hostStatusTone(status: string): 'success' | 'warning' | 'error' | 'neutral' { + const normalized = status.trim().toLowerCase(); + if (normalized === 'healthy') { + return 'success'; + } + if (normalized === 'degraded') { + return 'warning'; + } + if (normalized === 'offline' || normalized === 'unhealthy') { + return 'error'; + } + return 'neutral'; + } + + lastSeenLabel(): string { + const currentHost = this.host(); + if (!currentHost?.lastSeenAt) { + return 'No host heartbeat yet'; + } + + return new RelativeTimePipe().transform(currentHost.lastSeenAt) || currentHost.lastSeenAt; + } + + probeLastHeartbeatLabel(): string { + const currentHost = this.host(); + const value = currentHost?.probeLastHeartbeat ?? currentHost?.lastSeenAt; + if (!value) { + return 'No probe heartbeat yet'; + } + + return new RelativeTimePipe().transform(value) || value; + } + + private load(): void { + this.loading.set(true); + this.error.set(null); + + forkJoin({ + hosts: this.topologyApi.list('/api/v2/topology/hosts', this.context).pipe(catchError(() => of([]))), + targets: this.topologyApi.list('/api/v2/topology/targets', this.context).pipe(catchError(() => of([]))), + }) + .pipe(take(1)) + .subscribe({ + next: ({ hosts, targets }) => { + this.hosts.set(hosts); + this.targets.set(targets); + + if (!hosts.some((item) => item.hostId === this.hostId())) { + this.error.set(`Host ${this.hostId()} was not found in the current topology scope.`); + } + + this.loading.set(false); + }, + error: (err: unknown) => { + this.error.set(err instanceof Error ? err.message : 'Failed to load host detail.'); + this.loading.set(false); + }, + }); + } } - - diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts index f0a981dcf..ecafa7086 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts @@ -1,22 +1,36 @@ -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, 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 { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service'; +import { RelativeTimePipe } from '../../shared/pipes/format.pipes'; +import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component'; import { TopologyDataService } from './topology-data.service'; import { TopologyHost, TopologyTarget } from './topology.models'; +import { + hasRuntimeProbe, + inferConnectionProfile, + normalizeProbeStatus, + probeStatusLabel, + probeStatusTone, + probeTypeLabel, +} from './topology-runtime.helpers'; + +type HostStatusFilter = 'all' | 'healthy' | 'degraded' | 'offline' | 'unknown'; +type ProbeFilter = 'all' | 'monitored' | 'unmonitored'; @Component({ selector: 'app-topology-hosts-page', standalone: true, - imports: [FormsModule, RouterLink], + imports: [FormsModule, RouterLink, StatusBadgeComponent], template: `

Hosts

-

Operational host inventory with runtime, heartbeat, and target mapping.

+

Operational host inventory with runtime probe coverage, heartbeat freshness, and target mapping.

{{ context.regionSummary() }} @@ -39,7 +53,7 @@ import { TopologyHost, TopologyTarget } from './topology.models';
- +
+
+ + +
@if (error()) { @@ -56,15 +78,31 @@ import { TopologyHost, TopologyTarget } from './topology.models'; @if (loading()) {
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
} @else {
-

Hosts

+
+
+

Hosts

+

{{ monitoredHostsCount() }} monitored of {{ hosts().length }} in scope

+
+
@@ -74,24 +112,53 @@ import { TopologyHost, TopologyTarget } from './topology.models'; + + @for (host of filteredHosts(); track host.hostId) { - + - + + + } @empty { - + + + }
Environment Runtime StatusRuntime ProbeLast Seen Targets
{{ host.hostName }} +
+ {{ host.hostName }} + {{ host.hostId }} +
+
{{ host.regionId }} {{ host.environmentId }} {{ host.runtimeType }}{{ host.status }} + + +
+ + @if (showProbeType(host)) { + {{ probeType(host) }} + } +
+
{{ lastSeenLabel(host) }} {{ host.targetCount }}
- - No hosts for current filters. -
+ No hosts for current filters. +
@@ -101,24 +168,52 @@ import { TopologyHost, TopologyTarget } from './topology.models';

Selected Host

@if (selectedHost()) { -

{{ selectedHost()!.hostName }}

-
- Status{{ selectedHost()!.status }} - Runtime{{ selectedHost()!.runtimeType }} - Agent{{ selectedHost()!.agentId }} - Last seen{{ selectedHost()!.lastSeenAt ?? '-' }} - Impacted targets{{ selectedHostTargets().length }} - Upgrade windowFri 23:00 UTC +
+
+

{{ selectedHost()!.hostName }}

+

{{ selectedHost()!.regionId }} / {{ selectedHost()!.environmentId }}

+
+
+ +
+ Runtime + {{ selectedHost()!.runtimeType }} + + Host status + {{ selectedHost()!.status }} + + Connection + {{ selectedConnectionProfile().connectionLabel }} + + Endpoint + {{ selectedConnectionProfile().endpointLabel }}:{{ selectedConnectionProfile().portLabel }} + + Probe + {{ probeType(selectedHost()!) }} + + Last seen + {{ lastSeenLabel(selectedHost()!) }} + + Mapped targets + {{ selectedHostTargets().length }} +
+ +

{{ selectedConnectionProfile().summary }}

+ } @else {
- - Select a host row to inspect runtime drift and impact. + Select a host row to inspect runtime coverage and mapped targets.
}
@@ -168,7 +263,6 @@ import { TopologyHost, TopologyTarget } from './topology.models'; padding: 0.1rem 0.45rem; } - /* --- Filters --- */ .filters { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); @@ -180,8 +274,15 @@ import { TopologyHost, TopologyTarget } from './topology.models'; flex-wrap: wrap; } - .filters__item { display: grid; gap: 0.15rem; } - .filters__item--wide { flex: 1; min-width: 180px; } + .filters__item { + display: grid; + gap: 0.15rem; + } + + .filters__item--wide { + flex: 1; + min-width: 180px; + } .filters label { font-size: 0.67rem; @@ -209,7 +310,6 @@ import { TopologyHost, TopologyTarget } from './topology.models'; box-shadow: 0 0 0 2px var(--color-focus-ring); } - /* --- Banner --- */ .banner { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); @@ -225,7 +325,6 @@ import { TopologyHost, TopologyTarget } from './topology.models'; border-color: var(--color-status-error-border); } - /* --- Skeleton --- */ .skeleton-table { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); @@ -235,30 +334,43 @@ import { TopologyHost, TopologyTarget } from './topology.models'; gap: 0.5rem; } - .skeleton-row { display: flex; gap: 0.75rem; } + .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: 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%; } + .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; } } - /* --- Layout --- */ .split { display: grid; gap: 0.6rem; - grid-template-columns: 1.45fr 1fr; + grid-template-columns: 1.55fr 1fr; align-items: start; } @@ -268,12 +380,9 @@ import { TopologyHost, TopologyTarget } from './topology.models'; background: var(--color-surface-primary); padding: 0.7rem; display: grid; - gap: 0.4rem; - transition: box-shadow 180ms ease; + gap: 0.5rem; } - .card:hover { box-shadow: var(--shadow-sm); } - .card h2 { margin: 0; font-size: 0.92rem; @@ -281,18 +390,30 @@ import { TopologyHost, TopologyTarget } from './topology.models'; font-weight: 600; } - /* --- Table --- */ + .card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + } + + .card__header p { + margin: 0.15rem 0 0; + color: var(--color-text-secondary); + font-size: 0.74rem; + } + .table-wrap { - max-height: 420px; + max-height: 520px; overflow: auto; border-radius: var(--radius-sm); } - /* Table styling provided by global .stella-table class */ th { position: sticky; top: 0; z-index: 1; + white-space: nowrap; } tbody tr { @@ -300,41 +421,77 @@ import { TopologyHost, TopologyTarget } from './topology.models'; transition: background 120ms ease; } - tbody tr:nth-child(even) { background: var(--color-surface-primary); } - tbody tr:hover { background: var(--color-brand-soft); } + tbody tr:hover { + background: var(--color-brand-soft); + } tbody tr.active { background: var(--color-brand-primary-10); box-shadow: inset 3px 0 0 var(--color-brand-primary); } - .cell-name { font-weight: 500; } + .cell-host__main { + display: grid; + gap: 0.12rem; + } - /* --- Status badges --- */ - .status-badge { + .cell-host__link { + color: var(--color-text-link); + text-decoration: none; + font-weight: 600; + } + + .cell-host__link:hover { + text-decoration: underline; + } + + .cell-host__meta { + color: var(--color-text-muted); + font-size: 0.68rem; + font-family: var(--font-mono, monospace); + } + + .probe-cell { 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; + gap: 0.35rem; 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); } + .probe-cell__type { + color: var(--color-text-muted); + font-size: 0.68rem; + font-weight: 500; + } - /* --- Detail panel --- */ - .detail__name { margin: 0; font-size: 0.82rem; color: var(--color-text-primary); } + .detail { + align-content: start; + } + + .detail__hero { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + } + + .detail__name { + margin: 0; + font-size: 0.9rem; + font-weight: 700; + color: var(--color-text-primary); + } + + .detail__meta { + margin: 0.12rem 0 0; + color: var(--color-text-secondary); + font-size: 0.74rem; + } .detail__grid { display: grid; grid-template-columns: auto 1fr; - gap: 0.2rem 0.6rem; + gap: 0.25rem 0.6rem; font-size: 0.74rem; } @@ -347,12 +504,22 @@ import { TopologyHost, TopologyTarget } from './topology.models'; padding-top: 0.1rem; } - .detail__value { color: var(--color-text-secondary); } + .detail__value { + color: var(--color-text-secondary); + } - .detail p { margin: 0; color: var(--color-text-secondary); font-size: 0.75rem; } + .detail__summary { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.74rem; + line-height: 1.45; + } - /* --- Actions --- */ - .actions { display: flex; flex-wrap: wrap; gap: 0.35rem; } + .actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + } .actions a { display: inline-flex; @@ -372,39 +539,28 @@ import { TopologyHost, TopologyTarget } from './topology.models'; color: var(--color-text-link-hover); } - /* --- Empty states --- */ - .empty-cell { + .empty-cell, + .empty-state { 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; } - - .empty-state { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.35rem; - padding: 1rem; - color: var(--color-text-muted); - font-size: 0.76rem; - text-align: center; + @media (max-width: 1080px) { + .split { + grid-template-columns: 1fr; + } } - .empty-state__icon { opacity: 0.35; } - - .muted { color: var(--color-text-secondary); font-size: 0.74rem; } - @media (max-width: 960px) { - .filters { flex-direction: column; } - .filters__item--wide { min-width: auto; } - .split { grid-template-columns: 1fr; } + .filters { + flex-direction: column; + } + + .filters__item--wide { + min-width: auto; + } } `], changeDetection: ChangeDetectionStrategy.OnPush, @@ -412,45 +568,68 @@ import { TopologyHost, TopologyTarget } from './topology.models'; export class TopologyHostsPageComponent { private readonly topologyApi = inject(TopologyDataService); private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + private readonly helperCtx = inject(StellaHelperContextService); + readonly context = inject(PlatformContextStore); readonly loading = signal(false); readonly error = signal(null); readonly searchQuery = signal(''); readonly runtimeFilter = signal('all'); - readonly statusFilter = signal('all'); + readonly statusFilter = signal('all'); + readonly probeFilter = signal('all'); readonly selectedHostId = signal(''); readonly hosts = signal([]); readonly targets = signal([]); readonly runtimeOptions = computed(() => - [...new Set(this.hosts().map((item) => item.runtimeType))].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })), + [...new Set(this.hosts().map((item) => item.runtimeType))] + .filter((value) => value.trim().length > 0) + .sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })), ); readonly filteredHosts = computed(() => { const query = this.searchQuery().trim().toLowerCase(); const runtime = this.runtimeFilter(); const status = this.statusFilter(); + const probe = this.probeFilter(); return this.hosts().filter((item) => { const matchesQuery = !query || - [item.hostName, item.hostId, item.regionId, item.environmentId, item.runtimeType] - .some((value) => value.toLowerCase().includes(query)); + [ + item.hostName, + item.hostId, + item.regionId, + item.environmentId, + item.runtimeType, + item.agentId, + probeStatusLabel(normalizeProbeStatus(item.probeStatus)), + ].some((value) => value.toLowerCase().includes(query)); const matchesRuntime = runtime === 'all' || item.runtimeType === runtime; const normalizedStatus = item.status.trim().toLowerCase(); const matchesStatus = status === 'all' || normalizedStatus === status; - return matchesQuery && matchesRuntime && matchesStatus; + const monitored = hasRuntimeProbe(item); + const matchesProbe = + probe === 'all' || + (probe === 'monitored' && monitored) || + (probe === 'unmonitored' && !monitored); + + return matchesQuery && matchesRuntime && matchesStatus && matchesProbe; }); }); readonly selectedHost = computed(() => { const selectedId = this.selectedHostId(); + const filteredHosts = this.filteredHosts(); + if (!selectedId) { - return this.filteredHosts()[0] ?? null; + return filteredHosts[0] ?? null; } - return this.hosts().find((item) => item.hostId === selectedId) ?? null; + + return filteredHosts.find((item) => item.hostId === selectedId) ?? filteredHosts[0] ?? null; }); readonly selectedHostTargets = computed(() => { @@ -458,7 +637,34 @@ export class TopologyHostsPageComponent { if (!host) { return []; } - return this.targets().filter((item) => item.hostId === host.hostId); + + return this.targets() + .filter((item) => item.hostId === host.hostId) + .sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })); + }); + + readonly selectedConnectionProfile = computed(() => { + const host = this.selectedHost(); + if (!host) { + return inferConnectionProfile('-', 'docker_host'); + } + + return inferConnectionProfile(host.hostName, host.runtimeType); + }); + + readonly monitoredHostsCount = computed(() => this.hosts().filter((host) => hasRuntimeProbe(host)).length); + readonly helperContexts = computed(() => { + const contexts: string[] = []; + if (!this.loading() && this.filteredHosts().length === 0) { + contexts.push('empty-table'); + } + if ( + this.hosts().some((host) => host.status.trim().toLowerCase() === 'unknown') || + (!this.loading() && this.hosts().length > 0 && this.monitoredHostsCount() === 0) + ) { + contexts.push('health-unknown'); + } + return contexts; }); constructor() { @@ -469,6 +675,7 @@ export class TopologyHostsPageComponent { if (hostId) { this.selectedHostId.set(hostId); } + const environment = params.get('environment'); if (environment) { this.searchQuery.set(environment); @@ -479,6 +686,71 @@ export class TopologyHostsPageComponent { this.context.contextVersion(); this.load(); }); + + effect(() => { + this.helperCtx.setScope('topology-hosts', this.helperContexts()); + }, { allowSignalWrites: true }); + this.destroyRef.onDestroy(() => this.helperCtx.clearScope('topology-hosts')); + } + + hostDetailLink(hostId: string): string[] { + return ['/environments/hosts', hostId]; + } + + hostStatusTone(status: string): 'success' | 'warning' | 'error' | 'neutral' { + const normalized = status.trim().toLowerCase(); + if (normalized === 'healthy') { + return 'success'; + } + if (normalized === 'degraded') { + return 'warning'; + } + if (normalized === 'offline' || normalized === 'unhealthy') { + return 'error'; + } + return 'neutral'; + } + + probeTone(host: TopologyHost): 'success' | 'error' | 'neutral' { + return probeStatusTone(normalizeProbeStatus(host.probeStatus)); + } + + probeLabel(host: TopologyHost): string { + return probeStatusLabel(normalizeProbeStatus(host.probeStatus)); + } + + probeType(host: TopologyHost): string { + const normalizedStatus = normalizeProbeStatus(host.probeStatus); + if (normalizedStatus !== 'active') { + return probeTypeLabel(host.probeType ?? inferConnectionProfile(host.hostName, host.runtimeType).probeRecommendation); + } + + return probeTypeLabel(host.probeType ?? inferConnectionProfile(host.hostName, host.runtimeType).probeRecommendation); + } + + showProbeType(host: TopologyHost): boolean { + return normalizeProbeStatus(host.probeStatus) === 'active'; + } + + probeTooltip(host: TopologyHost): string { + const lastSeen = host.probeLastHeartbeat ?? host.lastSeenAt; + const status = this.probeLabel(host); + const type = this.probeType(host); + + if (!lastSeen) { + return `${status}${type ? ` - ${type}` : ''}`; + } + + return `${status}${type ? ` - ${type}` : ''}. Last heartbeat ${this.relativeTime(lastSeen)}.`; + } + + lastSeenLabel(host: TopologyHost): string { + const lastSeen = host.probeLastHeartbeat ?? host.lastSeenAt; + return lastSeen ? this.relativeTime(lastSeen) : '-'; + } + + private relativeTime(value: string): string { + return new RelativeTimePipe().transform(value) || value; } private load(): void { @@ -494,9 +766,11 @@ export class TopologyHostsPageComponent { next: ({ hosts, targets }) => { this.hosts.set(hosts); this.targets.set(targets); + if (!this.selectedHostId() && hosts.length > 0) { this.selectedHostId.set(hosts[0].hostId); } + this.loading.set(false); }, error: (err: unknown) => { @@ -508,5 +782,3 @@ export class TopologyHostsPageComponent { }); } } - - diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-runtime.helpers.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-runtime.helpers.ts new file mode 100644 index 000000000..f0903c5fa --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-runtime.helpers.ts @@ -0,0 +1,313 @@ +import type { + TopologyHost, + TopologyProbeStatus, + TopologyProbeType, + TopologyTarget, +} from './topology.models'; + +export type RuntimeVerificationStatus = + | 'verified' + | 'drift' + | 'offline' + | 'not_monitored'; + +export interface HostConnectionProfile { + connectionLabel: string; + endpointLabel: string; + portLabel: string; + shell: 'bash' | 'powershell'; + probeRecommendation: TopologyProbeType; + summary: string; +} + +export interface RuntimeVerificationSummary { + status: RuntimeVerificationStatus; + label: string; + detail: string; + probeTypeLabel: string; + lastVerifiedAt: string | null; + expectedReleaseVersion: string | null; + observedReleaseVersion: string | null; +} + +function normalizeToken(value: string | null | undefined): string { + return (value ?? '').trim().toLowerCase(); +} + +export function normalizeProbeStatus(value: string | null | undefined): TopologyProbeStatus { + switch (normalizeToken(value)) { + case 'active': + return 'active'; + case 'offline': + return 'offline'; + default: + return 'not_installed'; + } +} + +export function normalizeProbeType(value: string | null | undefined): TopologyProbeType | null { + switch (normalizeToken(value)) { + case 'ebpf': + return 'ebpf'; + case 'etw': + return 'etw'; + case 'dyld': + return 'dyld'; + default: + return null; + } +} + +export function probeStatusLabel(status: TopologyProbeStatus): string { + switch (status) { + case 'active': + return 'Active'; + case 'offline': + return 'Offline'; + default: + return 'Not installed'; + } +} + +export function probeTypeLabel(type: TopologyProbeType | null | undefined): string { + switch (normalizeProbeType(type)) { + case 'ebpf': + return 'eBPF'; + case 'etw': + return 'ETW'; + case 'dyld': + return 'dyld'; + default: + return 'Probe'; + } +} + +export function probeStatusTone( + status: TopologyProbeStatus, +): 'success' | 'error' | 'neutral' { + switch (status) { + case 'active': + return 'success'; + case 'offline': + return 'error'; + default: + return 'neutral'; + } +} + +export function runtimeVerificationTone( + status: RuntimeVerificationStatus, +): 'success' | 'warning' | 'error' | 'neutral' { + switch (status) { + case 'verified': + return 'success'; + case 'drift': + return 'warning'; + case 'offline': + return 'error'; + default: + return 'neutral'; + } +} + +export function hasRuntimeProbe(host: Pick | null | undefined): boolean { + return normalizeProbeStatus(host?.probeStatus) !== 'not_installed'; +} + +export function shortId(value: string | null | undefined, max = 12): string { + if (!value) { + return '-'; + } + + return value.length <= max ? value : `${value.slice(0, max)}...`; +} + +export function shortDigest(value: string | null | undefined, head = 18): string { + if (!value) { + return '-'; + } + + return value.length <= head ? value : `${value.slice(0, head)}...`; +} + +export function inferHostOsFamily(runtimeType: string | null | undefined): 'linux' | 'windows' | 'macos' { + const token = normalizeToken(runtimeType); + + if (token.includes('winrm') || token.includes('windows') || token.includes('etw')) { + return 'windows'; + } + + if (token.includes('mac') || token.includes('darwin') || token.includes('dyld')) { + return 'macos'; + } + + return 'linux'; +} + +export function inferProbeRecommendation(runtimeType: string | null | undefined): TopologyProbeType { + switch (inferHostOsFamily(runtimeType)) { + case 'windows': + return 'etw'; + case 'macos': + return 'dyld'; + default: + return 'ebpf'; + } +} + +export function inferConnectionProfile( + hostName: string, + runtimeType: string | null | undefined, +): HostConnectionProfile { + const token = normalizeToken(runtimeType); + + if (token.includes('winrm') || token.includes('windows')) { + return { + connectionLabel: 'WinRM', + endpointLabel: hostName, + portLabel: '5985 / 5986', + shell: 'powershell', + probeRecommendation: 'etw', + summary: 'Windows host connection is managed over WinRM. Exact credentials are not exposed by the topology read model.', + }; + } + + if (token.includes('ssh')) { + return { + connectionLabel: 'SSH', + endpointLabel: hostName, + portLabel: '22', + shell: 'bash', + probeRecommendation: 'ebpf', + summary: 'Linux or UNIX host connection is managed over SSH. Exact credentials are not exposed by the topology read model.', + }; + } + + if (token.includes('compose')) { + return { + connectionLabel: 'Docker Compose', + endpointLabel: hostName, + portLabel: '2375 or local socket', + shell: 'bash', + probeRecommendation: 'ebpf', + summary: 'Compose workloads run on a host-backed Docker runtime. Connection specifics are currently summarized rather than exposed directly.', + }; + } + + if (token.includes('nomad')) { + return { + connectionLabel: 'Nomad', + endpointLabel: hostName, + portLabel: '4646', + shell: 'bash', + probeRecommendation: 'ebpf', + summary: 'Runtime activity is managed through Nomad control-plane workflows, not direct host credentials in the topology read model.', + }; + } + + if (token.includes('ecs')) { + return { + connectionLabel: 'ECS', + endpointLabel: hostName, + portLabel: 'AWS API', + shell: 'bash', + probeRecommendation: 'ebpf', + summary: 'Runtime activity is managed through ECS service workflows, not direct host credentials in the topology read model.', + }; + } + + return { + connectionLabel: 'Docker Host', + endpointLabel: hostName, + portLabel: '2375 or local socket', + shell: 'bash', + probeRecommendation: inferProbeRecommendation(runtimeType), + summary: 'Host runtime is Docker-backed. Exact connection and secret references are not exposed by the topology read model.', + }; +} + +export function dominantReleaseVersion( + targets: readonly Pick[], +): string | null { + const counts = new Map(); + for (const target of targets) { + const version = (target.releaseVersionId ?? '').trim(); + if (!version) { + continue; + } + + counts.set(version, (counts.get(version) ?? 0) + 1); + } + + let bestVersion: string | null = null; + let bestCount = -1; + for (const version of [...counts.keys()].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))) { + const count = counts.get(version) ?? 0; + if (count > bestCount) { + bestCount = count; + bestVersion = version; + } + } + + return bestVersion; +} + +export function buildRuntimeVerificationSummary( + target: Pick, + host: Pick | null | undefined, + expectedReleaseVersion: string | null, +): RuntimeVerificationSummary { + const probeStatus = normalizeProbeStatus(host?.probeStatus); + const inferredProbeType = inferProbeRecommendation(host?.runtimeType); + const probeType = normalizeProbeType(host?.probeType) ?? inferredProbeType; + const lastVerifiedAt = host?.probeLastHeartbeat ?? host?.lastSeenAt ?? null; + const observedReleaseVersion = (target.releaseVersionId ?? '').trim() || null; + const expectedVersion = (expectedReleaseVersion ?? '').trim() || null; + const hostLabel = host?.hostName ?? target.hostId; + + if (probeStatus === 'not_installed') { + return { + status: 'not_monitored', + label: 'Not monitored', + detail: `No runtime probe heartbeat is currently bound to ${hostLabel}.`, + probeTypeLabel: probeTypeLabel(probeType), + lastVerifiedAt, + expectedReleaseVersion: expectedVersion, + observedReleaseVersion, + }; + } + + if (probeStatus === 'offline') { + return { + status: 'offline', + label: 'Offline', + detail: `${probeTypeLabel(probeType)} heartbeat for ${hostLabel} is stale.`, + probeTypeLabel: probeTypeLabel(probeType), + lastVerifiedAt, + expectedReleaseVersion: expectedVersion, + observedReleaseVersion, + }; + } + + if (expectedVersion && observedReleaseVersion && expectedVersion !== observedReleaseVersion) { + return { + status: 'drift', + label: 'Drift', + detail: `Observed release ${shortId(observedReleaseVersion)} differs from the dominant environment release ${shortId(expectedVersion)}.`, + probeTypeLabel: probeTypeLabel(probeType), + lastVerifiedAt, + expectedReleaseVersion: expectedVersion, + observedReleaseVersion, + }; + } + + return { + status: 'verified', + label: 'Verified', + detail: `Active ${probeTypeLabel(probeType)} heartbeat is present for ${hostLabel}.`, + probeTypeLabel: probeTypeLabel(probeType), + lastVerifiedAt, + expectedReleaseVersion: expectedVersion, + observedReleaseVersion, + }; +} diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts index 179c413f8..a01521ffd 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts @@ -6,6 +6,9 @@ export interface PlatformListResponse { offset?: number; } +export type TopologyProbeStatus = 'active' | 'offline' | 'not_installed'; +export type TopologyProbeType = 'ebpf' | 'etw' | 'dyld'; + export interface TopologyRegion { regionId: string; displayName: string; @@ -57,6 +60,9 @@ export interface TopologyHost { agentId: string; targetCount: number; lastSeenAt: string | null; + probeStatus?: TopologyProbeStatus | null; + probeType?: TopologyProbeType | null; + probeLastHeartbeat?: string | null; } export interface TopologyAgent { @@ -128,4 +134,3 @@ export interface ReadinessReport { evaluatedAt: string; } -