feat(web): complete topology host verification ui

This commit is contained in:
master
2026-03-31 23:24:10 +03:00
parent 5bb5596e2f
commit 404d50bcb7
11 changed files with 2422 additions and 250 deletions

View File

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

View File

@@ -10,13 +10,15 @@
- Selector: `app-topology-environment-detail-page` - Selector: `app-topology-environment-detail-page`
## What Is It? ## 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 ## 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 ## 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 ## Likely Successor Or Merge Target
Needs branch-level review against current IA Needs branch-level review against current IA
@@ -40,13 +42,16 @@ Needs branch-level review against current IA
- none - none
### Runtime references outside routes/tests ### 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 ## Related Docs
- [docs/modules/ui/README.md](../../../../../README.md) - [docs/modules/ui/README.md](../../../../../README.md)
- [docs/modules/ui/architecture.md](../../../../../architecture.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 ## Next-Pass Questions
- Confirm whether the route is reachable only through relative child-tab navigation. - Confirm whether the environment detail route should gain a more explicit shell entry from inventory views.
- Check the corresponding product/docs promise before treating the page as dropped. - Replace dominant-release heuristics with backend running-inventory evidence when Sprint 002 follow-on APIs land.
- Verify whether the route should be linked from the current shell or intentionally remain deep-linked only. - Decide whether runtime verification should be promoted into overview KPIs after backend evidence becomes richer.

View File

@@ -10,13 +10,15 @@
- Selector: `app-topology-host-detail-page` - Selector: `app-topology-host-detail-page`
## What Is It? ## 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 ## 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 ## 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 ## Likely Successor Or Merge Target
Needs branch-level review against current IA Needs branch-level review against current IA
@@ -33,16 +35,17 @@ Needs branch-level review against current IA
- none - none
### Absolute page-action surfaces ### Absolute page-action surfaces
- none - Host links on `TopologyHostsPageComponent` route into this detail page.
### Runtime references outside routes/tests ### Runtime references outside routes/tests
- none - `src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts`
## Related Docs ## Related Docs
- [docs/modules/ui/README.md](../../../../../README.md) - [docs/modules/ui/README.md](../../../../../README.md)
- [docs/modules/ui/architecture.md](../../../../../architecture.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 ## Next-Pass Questions
- Confirm whether the route is reachable only through relative child-tab navigation. - Decide whether the host detail page should gain a direct shell affordance beyond the hosts-table deep link.
- Check the corresponding product/docs promise before treating the page as dropped. - Confirm when backend host connection metadata can replace the current truthful derived summary.
- Verify whether the route should be linked from the current shell or intentionally remain deep-linked only. - Confirm when probe telemetry expands beyond heartbeat state into richer health counters.

View File

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

View File

@@ -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<typeof convertToParamMap>) => 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();
});
});

View File

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

View File

@@ -1,5 +1,5 @@
import { HttpClient, HttpParams } from '@angular/common/http'; 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 { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs'; import { catchError, forkJoin, of, take } from 'rxjs';
@@ -25,11 +25,20 @@ import {
TopologyPromotionPath, TopologyPromotionPath,
TopologyTarget, TopologyTarget,
} from './topology.models'; } 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 { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component'; import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component';
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component'; import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
import { StatGroupComponent } from '../../shared/components/stat-card/stat-card.component'; import { StatGroupComponent } from '../../shared/components/stat-card/stat-card.component';
import { RelativeTimePipe, DurationPipe } from '../../shared/pipes/format.pipes'; 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'; 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: ` template: `
<section class="env-detail"> <section class="env-detail">
<!-- ── Header ── --> <!-- Header -->
<header class="hdr"> <header class="hdr">
<a class="hdr__back" routerLink="/environments/overview"> Environments</a> <a class="hdr__back" routerLink="/environments/overview">&lt;- Environments</a>
<div class="hdr__main"> <div class="hdr__main">
<div class="hdr__title-row"> <div class="hdr__title-row">
<h1>{{ environmentLabel() }}</h1> <h1>{{ environmentLabel() }}</h1>
@@ -80,12 +89,12 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
@if (currentRelease()) { <span class="chip chip--release">{{ currentRelease() }}</span> } @if (currentRelease()) { <span class="chip chip--release">{{ currentRelease() }}</span> }
@if (isFrozen()) { <app-status-badge status="error" label="FROZEN" [showIcon]="true" size="sm" /> } @if (isFrozen()) { <app-status-badge status="error" label="FROZEN" [showIcon]="true" size="sm" /> }
</div> </div>
<p class="hdr__sub">{{ regionLabel() }} · {{ environmentTypeLabel() }}</p> <p class="hdr__sub">{{ regionLabel() }} | {{ environmentTypeLabel() }}</p>
@if (promotionLine()) { <p class="hdr__promo">{{ promotionLine() }}</p> } @if (promotionLine()) { <p class="hdr__promo">{{ promotionLine() }}</p> }
</div> </div>
<div class="hdr__actions"> <div class="hdr__actions">
<button class="btn btn--secondary btn--sm" (click)="refresh()">Refresh</button> <button class="btn btn--secondary btn--sm" (click)="refresh()">Refresh</button>
<a class="btn btn--primary btn--sm" [routerLink]="['/releases/deployments/new']" [queryParams]="{ environment: environmentId() }">Deploy</a> <a class="btn btn--primary btn--sm" [routerLink]="['/releases/promotions/create']" [queryParams]="{ targetEnvironmentId: environmentId() }">Request Promotion</a>
</div> </div>
</header> </header>
@@ -104,7 +113,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
} @else { } @else {
@switch (activeTab()) { @switch (activeTab()) {
<!-- ════════ OVERVIEW ════════ --> <!-- Overview -->
@case ('overview') { @case ('overview') {
<div class="overview-layout"> <div class="overview-layout">
<div class="overview-main"> <div class="overview-main">
@@ -135,7 +144,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
<article class="panel"> <article class="panel">
<div class="panel__hdr"> <div class="panel__hdr">
<h2>Readiness Snapshot</h2> <h2>Readiness Snapshot</h2>
<a class="panel__link" (click)="activeTab.set('readiness')">View full </a> <a class="panel__link" (click)="activeTab.set('readiness')">View full -></a>
</div> </div>
<div class="readiness-mini"> <div class="readiness-mini">
<span class="rm rm--ok">{{ readyTargetsCnt() }} pass</span> <span class="rm rm--ok">{{ readyTargetsCnt() }} pass</span>
@@ -170,30 +179,67 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
</div> </div>
} }
<!-- ════════ TARGETS ════════ --> <!-- Targets -->
@case ('targets') { @case ('targets') {
<article class="panel"> <app-stat-group columns="4">
<table class="stella-table stella-table--striped stella-table--hoverable"> <app-metric-card label="Verified" [value]="runtimeVerifiedCount()" [severity]="runtimeVerifiedCount() === targetRows().length && targetRows().length > 0 ? 'healthy' : 'warning'" />
<thead><tr><th>Target</th><th>Type</th><th>Host</th><th>Agent</th><th>Status</th><th>Last Sync</th></tr></thead> <app-metric-card label="Drift" [value]="runtimeDriftCount()" [severity]="runtimeDriftCount() > 0 ? 'warning' : 'healthy'" />
<tbody> <app-metric-card label="Probe Offline" [value]="runtimeOfflineCount()" [severity]="runtimeOfflineCount() > 0 ? 'critical' : 'healthy'" />
@for (t of targetRows(); track t.targetId) { <app-metric-card label="Not Monitored" [value]="runtimeUnmonitoredCount()" [severity]="runtimeUnmonitoredCount() > 0 ? 'warning' : 'healthy'" />
<tr> </app-stat-group>
<td>{{ t.name }}</td>
<td>{{ t.targetType }}</td> @if (targetRows().length === 0) {
<td>{{ hostName(t.hostId) }}</td> <article class="panel panel--empty">
<td>{{ agentName(t.agentId) }}</td> <div class="empty-panel">
<td><app-status-badge [status]="healthToStatus(t.healthStatus)" [label]="t.healthStatus" [showIcon]="true" size="sm" /></td> <div class="empty-panel__icon" aria-hidden="true">O</div>
<td>{{ t.lastSyncAt | relativeTime }}</td> <div class="empty-panel__body">
</tr> <h3>No targets are mapped to this environment yet</h3>
} @empty { <p>
<tr><td colspan="6" class="muted">No targets in this environment.</td></tr> 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.
</tbody> </p>
</table> </div>
</article> <div class="empty-panel__actions">
<a class="btn btn--primary btn--sm" [routerLink]="['/environments/targets']" [queryParams]="{ environment: environmentId() }">Review target inventory</a>
<a class="btn btn--secondary btn--sm" [routerLink]="['/ops/integrations']">Open integrations</a>
</div>
</div>
</article>
} @else {
<article class="panel">
<p class="runtime-note">
Runtime state combines host probe heartbeat plus the dominant deployed release version for this environment. Missing probe data renders as Not monitored.
</p>
<table class="stella-table stella-table--striped stella-table--hoverable">
<thead><tr><th>Target</th><th>Type</th><th>Host</th><th>Agent</th><th>Status</th><th>Runtime</th><th>Last Sync</th></tr></thead>
<tbody>
@for (row of runtimeVerificationRows(); track row.target.targetId) {
<tr
[class.runtime-row--drift]="row.summary.status === 'drift'"
[class.runtime-row--offline]="row.summary.status === 'offline'"
[class.runtime-row--unmonitored]="row.summary.status === 'not_monitored'"
>
<td>{{ row.target.name }}</td>
<td>{{ row.target.targetType }}</td>
<td>{{ hostName(row.target.hostId) }}</td>
<td>{{ agentName(row.target.agentId) }}</td>
<td><app-status-badge [status]="healthToStatus(row.target.healthStatus)" [label]="row.target.healthStatus" [showIcon]="true" size="sm" /></td>
<td>
<div class="runtime-cell" [title]="row.summary.detail">
<app-status-badge [status]="runtimeStatusTone(row.summary.status)" [label]="row.summary.label" [showIcon]="true" size="sm" />
<span class="runtime-caption">{{ row.summary.probeTypeLabel }}</span>
</div>
</td>
<td>{{ row.target.lastSyncAt | relativeTime }}</td>
</tr>
}
</tbody>
</table>
</article>
}
} }
<!-- ════════ READINESS ════════ --> <!-- Readiness -->
@case ('readiness') { @case ('readiness') {
<app-stat-group columns="3"> <app-stat-group columns="3">
<app-metric-card label="Ready" [value]="readyTargetsCnt()" [severity]="readyTargetsCnt() === readinessReports().length ? 'healthy' : 'warning'" /> <app-metric-card label="Ready" [value]="readyTargetsCnt()" [severity]="readyTargetsCnt() === readinessReports().length ? 'healthy' : 'warning'" />
@@ -201,49 +247,77 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
<app-metric-card label="Pending" [value]="pendingTargetsCnt()" [severity]="pendingTargetsCnt() > 0 ? 'warning' : 'healthy'" /> <app-metric-card label="Pending" [value]="pendingTargetsCnt()" [severity]="pendingTargetsCnt() > 0 ? 'warning' : 'healthy'" />
</app-stat-group> </app-stat-group>
<article class="panel"> @if (readinessReports().length === 0) {
<div class="panel__hdr"> <article class="panel panel--empty">
<h2>Gate Status</h2> <div class="empty-panel">
<button class="btn btn--primary btn--sm" (click)="validateAll()" [disabled]="validatingAll()"> <div class="empty-panel__icon" aria-hidden="true">OK</div>
{{ validatingAll() ? 'Validating...' : 'Validate All' }} <div class="empty-panel__body">
</button> <h3>No readiness checks have been captured yet</h3>
</div> <p>
<div class="gg-wrap"> Readiness records show whether agents are bound, registries are reachable, and connectivity gates are passing for each target.
<table class="stella-table stella-table--striped stella-table--hoverable"> @if (targetRows().length === 0) {
<thead> Add targets to this environment first, then Stella can validate promotion readiness.
<tr> } @else {
<th>Target</th> Run validation once to populate the gate matrix before reviewing or promoting this environment.
@for (gn of gateNames; track gn) { <th class="th-gate">{{ fmtGate(gn) }}</th> } }
<th>Ready</th> </p>
<th>Actions</th> </div>
</tr> <div class="empty-panel__actions">
</thead> @if (targetRows().length === 0) {
<tbody> <a class="btn btn--primary btn--sm" [routerLink]="['/environments/targets']" [queryParams]="{ environment: environmentId() }">Review targets</a>
@for (rpt of readinessReports(); track rpt.targetId) { <a class="btn btn--secondary btn--sm" [routerLink]="['/ops/integrations']">Open integrations</a>
<tr> } @else {
<td>{{ targetName(rpt.targetId) }}</td> <button class="btn btn--primary btn--sm" (click)="validateAll()" [disabled]="validatingAll()">
@for (gn of gateNames; track gn) { {{ validatingAll() ? 'Validating...' : 'Validate All' }}
<td class="td-gate"> </button>
<app-status-badge [status]="gateStatus(rpt, gn)" [label]="gateLabel(rpt, gn)" size="sm" /> <button class="btn btn--secondary btn--sm" (click)="activeTab.set('targets')">Inspect targets</button>
</td>
}
<td><app-status-badge [status]="rpt.isReady ? 'success' : 'error'" [label]="rpt.isReady ? 'Yes' : 'No'" [showIcon]="true" size="sm" /></td>
<td>
<button class="btn btn--secondary btn--xs" (click)="validateTarget(rpt.targetId)" [disabled]="validating().has(rpt.targetId)">
{{ validating().has(rpt.targetId) ? '...' : 'Validate' }}
</button>
</td>
</tr>
} @empty {
<tr><td [attr.colspan]="gateNames.length + 3" class="muted">No readiness data. Run validation to check targets.</td></tr>
} }
</tbody> </div>
</table> </div>
</div> </article>
</article> } @else {
<article class="panel">
<div class="panel__hdr">
<h2>Gate Status</h2>
<button class="btn btn--primary btn--sm" (click)="validateAll()" [disabled]="validatingAll()">
{{ validatingAll() ? 'Validating...' : 'Validate All' }}
</button>
</div>
<div class="gg-wrap">
<table class="stella-table stella-table--striped stella-table--hoverable">
<thead>
<tr>
<th>Target</th>
@for (gn of gateNames; track gn) { <th class="th-gate">{{ fmtGate(gn) }}</th> }
<th>Ready</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (rpt of readinessReports(); track rpt.targetId) {
<tr>
<td>{{ targetName(rpt.targetId) }}</td>
@for (gn of gateNames; track gn) {
<td class="td-gate">
<app-status-badge [status]="gateStatus(rpt, gn)" [label]="gateLabel(rpt, gn)" size="sm" />
</td>
}
<td><app-status-badge [status]="rpt.isReady ? 'success' : 'error'" [label]="rpt.isReady ? 'Yes' : 'No'" [showIcon]="true" size="sm" /></td>
<td>
<button class="btn btn--secondary btn--xs" (click)="validateTarget(rpt.targetId)" [disabled]="validating().has(rpt.targetId)">
{{ validating().has(rpt.targetId) ? '...' : 'Validate' }}
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</article>
}
} }
<!-- ════════ RUNS ════════ --> <!-- Runs -->
@case ('deployments') { @case ('deployments') {
<article class="panel"> <article class="panel">
<table class="stella-table stella-table--striped stella-table--hoverable"> <table class="stella-table stella-table--striped stella-table--hoverable">
@@ -253,9 +327,9 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
<tr> <tr>
<td>{{ run.releaseName }}</td> <td>{{ run.releaseName }}</td>
<td><app-status-badge [status]="runStatusType(run.status)" [label]="run.status" size="sm" /></td> <td><app-status-badge [status]="runStatusType(run.status)" [label]="run.status" size="sm" /></td>
<td>{{ run.durationMs ? (run.durationMs | duration) : '' }}</td> <td>{{ run.durationMs ? (run.durationMs | duration) : '-' }}</td>
<td>{{ run.occurredAt | relativeTime }}</td> <td>{{ run.occurredAt | relativeTime }}</td>
<td><a class="link" [routerLink]="['/releases/runs', run.activityId, 'summary']">View </a></td> <td><a class="link" [routerLink]="['/releases/runs', run.activityId, 'summary']">View -></a></td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="5" class="muted">No deployment runs in this scope.</td></tr> <tr><td colspan="5" class="muted">No deployment runs in this scope.</td></tr>
@@ -265,29 +339,46 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
</article> </article>
} }
<!-- ════════ AGENTS ════════ --> <!-- Agents -->
@case ('agents') { @case ('agents') {
<article class="panel"> @if (agentRows().length === 0) {
<table class="stella-table stella-table--striped stella-table--hoverable"> <article class="panel panel--empty">
<thead><tr><th>Agent</th><th>Status</th><th>Capabilities</th><th>Targets</th><th>Heartbeat</th></tr></thead> <div class="empty-panel">
<tbody> <div class="empty-panel__icon" aria-hidden="true">CFG</div>
@for (a of agentRows(); track a.agentId) { <div class="empty-panel__body">
<tr> <h3>No agents are reporting for this environment</h3>
<td>{{ a.agentName }}</td> <p>
<td><app-status-badge [status]="a.status === 'active' ? 'success' : 'warning'" [label]="a.status" [showIcon]="true" size="sm" /></td> Agents are what let Stella validate readiness and execute deployments on real hosts.
<td>{{ a.capabilities.join(', ') || '—' }}</td> Until an agent is connected here, this environment can describe topology but cannot perform release work.
<td>{{ a.assignedTargetCount }}</td> </p>
<td>{{ a.lastHeartbeatAt | relativeTime }}</td> </div>
</tr> <div class="empty-panel__actions">
} @empty { <a class="btn btn--primary btn--sm" [routerLink]="['/ops/operations/agents']">Open agent fleet</a>
<tr><td colspan="5" class="muted">No agents in this environment.</td></tr> <a class="btn btn--secondary btn--sm" [routerLink]="['/ops/operations/doctor']">Run diagnostics</a>
} </div>
</tbody> </div>
</table> </article>
</article> } @else {
<article class="panel">
<table class="stella-table stella-table--striped stella-table--hoverable">
<thead><tr><th>Agent</th><th>Status</th><th>Capabilities</th><th>Targets</th><th>Heartbeat</th></tr></thead>
<tbody>
@for (a of agentRows(); track a.agentId) {
<tr>
<td>{{ a.agentName }}</td>
<td><app-status-badge [status]="a.status === 'active' ? 'success' : 'warning'" [label]="a.status" [showIcon]="true" size="sm" /></td>
<td>{{ a.capabilities.join(', ') || '-' }}</td>
<td>{{ a.assignedTargetCount }}</td>
<td>{{ a.lastHeartbeatAt | relativeTime }}</td>
</tr>
}
</tbody>
</table>
</article>
}
} }
<!-- ════════ SECURITY ════════ --> <!-- Security -->
@case ('security') { @case ('security') {
<article class="panel"> <article class="panel">
<table class="stella-table stella-table--striped stella-table--hoverable"> <table class="stella-table stella-table--striped stella-table--hoverable">
@@ -297,14 +388,14 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
<tr> <tr>
<td class="mono">{{ f.cveId }}</td> <td class="mono">{{ f.cveId }}</td>
<td><app-status-badge [status]="severityStatus(f.severity)" [label]="f.severity" [showIcon]="true" size="sm" /></td> <td><app-status-badge [status]="severityStatus(f.severity)" [label]="f.severity" [showIcon]="true" size="sm" /></td>
<td>{{ f.cvss ?? '' }}</td> <td>{{ f.cvss ?? '-' }}</td>
<td> <td>
@if (f.reachable != null) { @if (f.reachable != null) {
<app-status-badge [status]="f.reachable ? 'error' : 'neutral'" [label]="f.reachable ? 'Yes' : 'No'" size="sm" /> <app-status-badge [status]="f.reachable ? 'error' : 'neutral'" [label]="f.reachable ? 'Yes' : 'No'" size="sm" />
} @else { } } @else { - }
</td> </td>
<td>{{ f.effectiveDisposition }}</td> <td>{{ f.effectiveDisposition }}</td>
<td><a class="link" [routerLink]="['/triage/artifacts']" [queryParams]="{ cve: f.cveId }">View </a></td> <td><a class="link" [routerLink]="['/triage/artifacts']" [queryParams]="{ cve: f.cveId }">View -></a></td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="6" class="muted">No active findings in this scope.</td></tr> <tr><td colspan="6" class="muted">No active findings in this scope.</td></tr>
@@ -314,7 +405,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
</article> </article>
} }
<!-- ════════ EVIDENCE ════════ --> <!-- Evidence -->
@case ('evidence') { @case ('evidence') {
@if (capsuleRows().length === 0) { @if (capsuleRows().length === 0) {
<div class="panel"><p class="muted">No decision capsules in this scope.</p></div> <div class="panel"><p class="muted">No decision capsules in this scope.</p></div>
@@ -348,7 +439,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
} }
} }
<!-- ════════ DRIFT ════════ --> <!-- Drift -->
@case ('drift') { @case ('drift') {
<article class="panel"> <article class="panel">
@if (driftDetected()) { @if (driftDetected()) {
@@ -365,9 +456,74 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
</div> </div>
} }
</article> </article>
<article class="panel">
<div class="panel__hdr">
<div>
<h2>Runtime Verification</h2>
<p class="runtime-note">
Probe-backed runtime status is shown here. True running-vs-deployed container digest comparison is not available until the backend exposes running inventory evidence.
</p>
</div>
<button class="btn btn--secondary btn--xs" (click)="runtimeSectionExpanded.set(!runtimeSectionExpanded())">
{{ runtimeSectionExpanded() ? 'Collapse' : 'Expand' }}
</button>
</div>
<div class="runtime-summary">
<span class="runtime-summary__item runtime-summary__item--verified">{{ runtimeVerifiedCount() }} verified</span>
<span class="runtime-summary__item runtime-summary__item--drift">{{ runtimeDriftCount() }} drift</span>
<span class="runtime-summary__item runtime-summary__item--offline">{{ runtimeOfflineCount() }} offline</span>
<span class="runtime-summary__item runtime-summary__item--unmonitored">{{ runtimeUnmonitoredCount() }} not monitored</span>
</div>
@if (runtimeSectionExpanded()) {
<div class="runtime-matrix-wrap">
<table class="stella-table stella-table--striped stella-table--hoverable">
<thead>
<tr>
<th>Target</th>
<th>Host</th>
<th>Probe</th>
<th>Expected Release</th>
<th>Observed Release</th>
<th>Image Digest</th>
<th>Runtime</th>
</tr>
</thead>
<tbody>
@for (row of runtimeVerificationRows(); track row.target.targetId) {
<tr
[class.runtime-row--drift]="row.summary.status === 'drift'"
[class.runtime-row--offline]="row.summary.status === 'offline'"
[class.runtime-row--unmonitored]="row.summary.status === 'not_monitored'"
>
<td>{{ row.target.name }}</td>
<td>{{ hostName(row.target.hostId) }}</td>
<td>{{ row.summary.probeTypeLabel }}</td>
<td class="mono" [title]="row.summary.expectedReleaseVersion">{{ shortId(row.summary.expectedReleaseVersion) }}</td>
<td class="mono" [title]="row.summary.observedReleaseVersion">{{ shortId(row.summary.observedReleaseVersion) }}</td>
<td class="mono" [title]="row.target.imageDigest">{{ shortDigest(row.target.imageDigest) }}</td>
<td>
<div class="runtime-cell" [title]="row.summary.detail">
<app-status-badge [status]="runtimeStatusTone(row.summary.status)" [label]="row.summary.label" [showIcon]="true" size="sm" />
@if (row.summary.lastVerifiedAt) {
<span class="runtime-caption">{{ row.summary.lastVerifiedAt | relativeTime }}</span>
}
</div>
</td>
</tr>
} @empty {
<tr><td colspan="7" class="muted">No target runtime verification data in this scope.</td></tr>
}
</tbody>
</table>
</div>
}
</article>
} }
<!-- ════════ DATA QUALITY ════════ --> <!-- Data quality -->
@case ('data-quality') { @case ('data-quality') {
<app-stat-group columns="4"> <app-stat-group columns="4">
<app-metric-card label="Target Coverage" [value]="targetRows().length" [severity]="targetRows().length === 0 ? 'critical' : 'healthy'" subtitle="Topology targets in scope" /> <app-metric-card label="Target Coverage" [value]="targetRows().length" [severity]="targetRows().length === 0 ? 'critical' : 'healthy'" subtitle="Topology targets in scope" />
@@ -383,7 +539,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
styles: [` styles: [`
.env-detail { display: grid; gap: 0.75rem; } .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 { 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 { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); text-decoration: none; }
.hdr__back:hover { text-decoration: underline; } .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; } .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); } .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 { 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--sm { padding: 0.28rem 0.6rem; }
.btn--xs { padding: 0.2rem 0.4rem; font-size: 0.68rem; } .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; } .link:hover { text-decoration: underline; }
.mono { font-family: var(--font-mono, monospace); font-size: 0.72rem; } .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 { 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 h2 { margin: 0; font-size: 0.88rem; font-weight: 600; }
.panel__hdr { display: flex; justify-content: space-between; align-items: center; } .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 { 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__link:hover { text-decoration: underline; }
.panel__ok { margin: 0; color: var(--color-status-success, #22c55e); font-size: 0.76rem; } .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 { 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)); } .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; } .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-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 0.65rem; }
.overview-main { display: grid; gap: 0.65rem; } .overview-main { display: grid; gap: 0.65rem; }
.overview-side { display: grid; gap: 0.65rem; align-content: start; } .overview-side { display: grid; gap: 0.65rem; align-content: start; }
/* ── Blocker list ── */ /* Blocker list */
.blocker-list { display: grid; gap: 0.3rem; } .blocker-list { display: grid; gap: 0.3rem; }
.blocker-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.76rem; } .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; } .readiness-mini { display: flex; gap: 0.75rem; font-size: 0.78rem; font-weight: 600; }
.rm--ok { color: var(--color-status-success, #22c55e); } .rm--ok { color: var(--color-status-success, #22c55e); }
.rm--fail { color: var(--color-status-error, #ef4444); } .rm--fail { color: var(--color-status-error, #ef4444); }
.rm--pend { color: var(--color-status-warning, #f59e0b); } .rm--pend { color: var(--color-status-warning, #f59e0b); }
/* ── Health circle ── */ /* Health circle */
.health-circle-panel { display: flex; justify-content: center; padding: 1rem; } .health-circle-panel { display: flex; justify-content: center; padding: 1rem; }
.health-circle { .health-circle {
width: 80px; height: 80px; border-radius: 50%; 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__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; } .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; } .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 { display: flex; flex-direction: column; align-items: center; }
.qs__v { font-size: 1rem; font-weight: 700; color: var(--color-text-heading); } .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; } .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; } .gg-wrap { overflow-x: auto; }
.th-gate, .td-gate { text-align: center; font-size: 0.68rem; } .th-gate, .td-gate { text-align: center; font-size: 0.68rem; }
.th-gate { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; } .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; } .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 { 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; } .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__date { font-size: 0.66rem; color: var(--color-text-muted); }
.ev-card__foot { display: flex; gap: 0.3rem; } .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 { 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 { 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-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 { display: flex; align-items: center; gap: 0.5rem; }
.drift-ok p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); } .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) { @media (max-width: 1024px) {
.overview-layout { grid-template-columns: 1fr; } .overview-layout { grid-template-columns: 1fr; }
@@ -488,6 +683,8 @@ export class TopologyEnvironmentDetailPageComponent {
private readonly topologySetup = inject(TopologySetupClient); private readonly topologySetup = inject(TopologySetupClient);
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly helperCtx = inject(StellaHelperContextService);
readonly context = inject(PlatformContextStore); readonly context = inject(PlatformContextStore);
readonly gateNames = GATE_NAMES; readonly gateNames = GATE_NAMES;
@@ -514,20 +711,53 @@ export class TopologyEnvironmentDetailPageComponent {
readonly currentRelease = signal<string | null>(null); readonly currentRelease = signal<string | null>(null);
readonly validating = signal<Set<string>>(new Set()); readonly validating = signal<Set<string>>(new Set());
readonly validatingAll = signal(false); 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 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 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 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]))); 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 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 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 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 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 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 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(() => { readonly deployHealth = computed(() => {
if (this.unhealthyTargets() > 0) return 'UNHEALTHY'; if (this.unhealthyTargets() > 0) return 'UNHEALTHY';
@@ -540,12 +770,12 @@ export class TopologyEnvironmentDetailPageComponent {
return h === 'HEALTHY' ? 'success' : h === 'DEGRADED' ? 'warning' : 'error'; return h === 'HEALTHY' ? 'success' : h === 'DEGRADED' ? 'warning' : 'error';
}); });
// ── Readiness computeds ── // Readiness computeds
readonly readyTargetsCnt = computed(() => this.readinessReports().filter(r => r.isReady).length); 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 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); 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(() => { readonly driftDetected = computed(() => {
const targets = this.targetRows(); const targets = this.targetRows();
if (targets.length < 2) return false; if (targets.length < 2) return false;
@@ -562,7 +792,30 @@ export class TopologyEnvironmentDetailPageComponent {
return targets.length - maxCount; 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(() => { readonly promotionLine = computed(() => {
const envId = this.environmentId(); const envId = this.environmentId();
const paths = this.promotionPaths(); 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 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 downstream = paths.filter(p => p.sourceEnvironmentId === envId).map(p => names.get(p.targetEnvironmentId) ?? p.targetEnvironmentId);
const parts: string[] = []; const parts: string[] = [];
if (upstream.length) parts.push(upstream.join(', ') + ' '); if (upstream.length) parts.push(upstream.join(', ') + ' ->');
parts.push(`[${this.environmentLabel()}]`); parts.push(`[${this.environmentLabel()}]`);
if (downstream.length) parts.push(' ' + downstream.join(', ')); if (downstream.length) parts.push('-> ' + downstream.join(', '));
return parts.join(' '); return parts.join(' ');
}); });
// ── Blockers ── // Blockers
readonly blockers = computed(() => { readonly blockers = computed(() => {
const items: { severity: 'error' | 'warning'; text: string }[] = []; const items: { severity: 'error' | 'warning'; text: string }[] = [];
if (this.unhealthyTargets() > 0) items.push({ severity: 'error', text: `${this.unhealthyTargets()} unhealthy target(s) require runtime remediation.` }); 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; return items;
}); });
// ── Tabs (dynamic status dots + badges) ── // Tabs (dynamic status dots + badges)
readonly tabDefs = computed((): StellaPageTab[] => [ 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: '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 }, { 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' }, { label: 'Runs', route: '/releases/deployments', description: 'Deployment runs' },
]); ]);
// ── Helpers ── // Helpers
hostName(id: string): string { return this.hostNameMap().get(id) ?? id.substring(0, 8) + '...'; } 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) + '...'; } 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); } targetName(id: string): string { return this.targetNameMap().get(id) ?? id.substring(0, 12); }
healthToStatus(h: string): 'success' | 'warning' | 'error' | 'neutral' { return healthToStatus(h); } healthToStatus(h: string): 'success' | 'warning' | 'error' | 'neutral' { return healthToStatus(h); }
severityStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { return severityToStatus(s); } severityStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { return severityToStatus(s); }
fmtGate(g: string): string { return fmtGateName(g); } 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' { gateStatus(rpt: ReadinessReport, gateName: string): 'success' | 'error' | 'warning' | 'neutral' {
const gate = rpt.gates.find(x => x.gateName === gateName); const gate = rpt.gates.find(x => x.gateName === gateName);
@@ -622,8 +878,8 @@ export class TopologyEnvironmentDetailPageComponent {
gateLabel(rpt: ReadinessReport, gateName: string): string { gateLabel(rpt: ReadinessReport, gateName: string): string {
const gate = rpt.gates.find(x => x.gateName === gateName); const gate = rpt.gates.find(x => x.gateName === gateName);
if (!gate) return ''; if (!gate) return '-';
switch (gate.status) { case 'pass': return ''; case 'fail': return ''; case 'pending': return ''; default: 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' { runStatusType(status: string): 'success' | 'warning' | 'error' | 'neutral' {
@@ -634,10 +890,14 @@ export class TopologyEnvironmentDetailPageComponent {
return 'neutral'; return 'neutral';
} }
// ── Lifecycle ── // Lifecycle
constructor() { constructor() {
this.context.initialize(); 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 => { this.route.paramMap.subscribe(params => {
const id = params.get('environmentId') ?? ''; const id = params.get('environmentId') ?? '';
this.environmentId.set(id); this.environmentId.set(id);
@@ -697,8 +957,14 @@ export class TopologyEnvironmentDetailPageComponent {
let best = ''; let bestCount = 0; let best = ''; let bestCount = 0;
for (const [v, c] of counts) if (c > bestCount) { best = v; bestCount = c; } for (const [v, c] of counts) if (c > bestCount) { best = v; bestCount = c; }
this.currentRelease.set(best.substring(0, 12)); 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); this.loading.set(false);
}, },
error: (err: unknown) => { error: (err: unknown) => {
@@ -708,7 +974,7 @@ export class TopologyEnvironmentDetailPageComponent {
}); });
} }
// ── Actions ── // Actions
validateTarget(targetId: string): void { validateTarget(targetId: string): void {
const s = new Set(this.validating()); s.add(targetId); this.validating.set(s); const s = new Set(this.validating()); s.add(targetId); this.validating.set(s);

View File

@@ -1,22 +1,698 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; 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({ @Component({
selector: 'app-topology-host-detail-page', selector: 'app-topology-host-detail-page',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterLink, RelativeTimePipe, CopyToClipboardComponent, StatusBadgeComponent],
template: ` template: `
<section class="topology-page"> <section class="host-detail">
<header> <header class="hero">
<h1>Host {{ hostId }}</h1> <div class="hero__nav">
<p>Inventory, connectivity tests, mapped targets, and agent diagnostics.</p> <a routerLink="/environments/hosts" class="back-link">Back to Hosts</a>
</div>
@if (host()) {
<div class="hero__main">
<div>
<div class="hero__title-row">
<h1>{{ host()!.hostName }}</h1>
<app-status-badge [status]="hostStatusTone(host()!.status)" [label]="host()!.status" [showIcon]="true" size="sm" />
<app-status-badge [status]="probeTone()" [label]="probeLabel()" [showIcon]="true" size="sm" />
</div>
<p class="hero__subtitle">{{ host()!.regionId }} / {{ host()!.environmentId }} / {{ host()!.runtimeType }}</p>
</div>
<div class="hero__chips">
<span class="chip">Targets {{ hostTargets().length }}</span>
<span class="chip">Probe {{ probeType() }}</span>
<span class="chip">Last seen {{ lastSeenLabel() }}</span>
</div>
</div>
} @else {
<div class="hero__main">
<div>
<h1>Host {{ hostId() }}</h1>
<p class="hero__subtitle">Waiting for topology data.</p>
</div>
</div>
}
</header> </header>
@if (error()) {
<div class="banner banner--error">{{ error() }}</div>
}
@if (loading()) {
<div class="loading">Loading host detail...</div>
} @else if (!host()) {
<div class="empty-state">
<p>No topology host matched {{ hostId() }} in the current scope.</p>
</div>
} @else {
<div class="layout">
<article class="card">
<h2>Host Overview</h2>
<div class="meta-grid">
<span class="meta-grid__label">Host ID</span>
<span class="meta-grid__value mono">{{ host()!.hostId }}</span>
<span class="meta-grid__label">Region</span>
<span class="meta-grid__value">{{ host()!.regionId }}</span>
<span class="meta-grid__label">Environment</span>
<span class="meta-grid__value">{{ host()!.environmentId }}</span>
<span class="meta-grid__label">Runtime</span>
<span class="meta-grid__value">{{ host()!.runtimeType }}</span>
<span class="meta-grid__label">Agent</span>
<span class="meta-grid__value">{{ host()!.agentId || 'Not assigned' }}</span>
<span class="meta-grid__label">Last seen</span>
<span class="meta-grid__value">{{ host()!.lastSeenAt ? (host()!.lastSeenAt | relativeTime) : 'No host heartbeat yet' }}</span>
</div>
</article>
<article class="card">
<h2>Connection Profile</h2>
<div class="meta-grid">
<span class="meta-grid__label">Connection</span>
<span class="meta-grid__value">{{ connectionProfile().connectionLabel }}</span>
<span class="meta-grid__label">Endpoint</span>
<span class="meta-grid__value">{{ connectionProfile().endpointLabel }}</span>
<span class="meta-grid__label">Port / Profile</span>
<span class="meta-grid__value">{{ connectionProfile().portLabel }}</span>
<span class="meta-grid__label">Credential ref</span>
<span class="meta-grid__value">Not exposed by topology API</span>
<span class="meta-grid__label">Connection test</span>
<span class="meta-grid__value">Use mapped target validation until host-level test endpoints exist.</span>
</div>
<p class="summary">{{ connectionProfile().summary }}</p>
<div class="actions">
<a [routerLink]="['/environments', 'targets']" [queryParams]="{ hostId: host()!.hostId }">Open mapped targets</a>
<a [routerLink]="['/ops/operations/agents']" [queryParams]="{ agentId: host()!.agentId }">Open agent</a>
</div>
</article>
<article class="card card--wide">
<div class="card__header">
<div>
<h2>Mapped Targets</h2>
<p>{{ hostTargets().length }} target(s) currently mapped to this host.</p>
</div>
</div>
<table class="stella-table stella-table--striped stella-table--hoverable">
<thead>
<tr>
<th>Target</th>
<th>Type</th>
<th>Status</th>
<th>Release</th>
<th>Image Digest</th>
<th></th>
</tr>
</thead>
<tbody>
@for (target of hostTargets(); track target.targetId) {
<tr>
<td>{{ target.name }}</td>
<td>{{ target.targetType }}</td>
<td>
<app-status-badge [status]="hostStatusTone(target.healthStatus)" [label]="target.healthStatus" [showIcon]="true" size="sm" />
</td>
<td class="mono" [title]="target.releaseVersionId">{{ shortId(target.releaseVersionId) }}</td>
<td class="mono" [title]="target.imageDigest">{{ shortDigest(target.imageDigest) }}</td>
<td><a [routerLink]="['/environments/targets', target.targetId]" class="link">Inspect</a></td>
</tr>
} @empty {
<tr>
<td colspan="6" class="muted">No mapped targets for this host.</td>
</tr>
}
</tbody>
</table>
</article>
<article class="card">
<div class="card__header">
<div>
<h2>Runtime Probe</h2>
<p>{{ probePanelSummary() }}</p>
</div>
</div>
@if (!hasProbe()) {
<div class="probe-empty">
<p>No runtime probe heartbeat is currently bound to this host.</p>
<div class="meta-grid">
<span class="meta-grid__label">Recommended probe</span>
<span class="meta-grid__value">{{ probeType() }}</span>
<span class="meta-grid__label">Command shell</span>
<span class="meta-grid__value">{{ connectionProfile().shell }}</span>
</div>
<label class="toggle">
<input type="checkbox" [checked]="enableRuntimeVerification()" (change)="enableRuntimeVerification.set(!enableRuntimeVerification())" />
<span>Include runtime verification flag in install command preview</span>
</label>
<div class="command-block">
<code>{{ installCommand() }}</code>
<app-copy-to-clipboard [value]="installCommand()" ariaLabel="Copy host install command" />
</div>
</div>
} @else {
<div class="meta-grid">
<span class="meta-grid__label">Probe status</span>
<span class="meta-grid__value">
<app-status-badge [status]="probeTone()" [label]="probeLabel()" [showIcon]="true" size="sm" />
</span>
<span class="meta-grid__label">Probe type</span>
<span class="meta-grid__value">{{ probeType() }}</span>
<span class="meta-grid__label">Last heartbeat</span>
<span class="meta-grid__value">{{ probeLastHeartbeatLabel() }}</span>
<span class="meta-grid__label">Coverage proxy</span>
<span class="meta-grid__value">{{ hostTargets().length }} mapped target(s)</span>
</div>
<p class="summary">
Exact probe health metrics such as CPU overhead, buffer utilization, and events per second are not yet exposed by the topology read model.
</p>
}
</article>
<article class="card">
<div class="card__header">
<div>
<h2>Recent Activity</h2>
<p>Latest topology sync rows derived from mapped targets.</p>
</div>
</div>
<table class="stella-table stella-table--striped">
<thead>
<tr>
<th>Target</th>
<th>Last Sync</th>
<th>Release</th>
<th>Digest</th>
<th>Drift</th>
</tr>
</thead>
<tbody>
@for (row of recentActivity(); track row.targetId) {
<tr [class.activity-row--drift]="row.drifted">
<td>{{ row.targetName }}</td>
<td>{{ row.lastSyncAt ? (row.lastSyncAt | relativeTime) : 'Never' }}</td>
<td class="mono" [title]="row.releaseVersionId">{{ shortId(row.releaseVersionId) }}</td>
<td class="mono" [title]="row.imageDigest">{{ shortDigest(row.imageDigest) }}</td>
<td>
<app-status-badge
[status]="row.drifted ? 'warning' : 'success'"
[label]="row.drifted ? 'Drifted' : 'In sync'"
size="sm"
/>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="muted">No target activity for this host yet.</td>
</tr>
}
</tbody>
</table>
</article>
</div>
}
</section> </section>
`, `,
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 { export class TopologyHostDetailPageComponent {
private readonly topologyApi = inject(TopologyDataService);
private readonly route = inject(ActivatedRoute); 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<string | null>(null);
readonly enableRuntimeVerification = signal(true);
readonly hosts = signal<TopologyHost[]>([]);
readonly targets = signal<TopologyTarget[]>([]);
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<HostActivityRow[]>(() => {
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<HostConnectionProfile>(() => {
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 ?? '<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='<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=<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<TopologyHost>('/api/v2/topology/hosts', this.context).pipe(catchError(() => of([]))),
targets: this.topologyApi.list<TopologyTarget>('/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);
},
});
}
} }

View File

@@ -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 { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs'; import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store'; 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 { TopologyDataService } from './topology-data.service';
import { TopologyHost, TopologyTarget } from './topology.models'; 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({ @Component({
selector: 'app-topology-hosts-page', selector: 'app-topology-hosts-page',
standalone: true, standalone: true,
imports: [FormsModule, RouterLink], imports: [FormsModule, RouterLink, StatusBadgeComponent],
template: ` template: `
<section class="hosts"> <section class="hosts">
<header class="hosts__header"> <header class="hosts__header">
<div> <div>
<h1>Hosts</h1> <h1>Hosts</h1>
<p>Operational host inventory with runtime, heartbeat, and target mapping.</p> <p>Operational host inventory with runtime probe coverage, heartbeat freshness, and target mapping.</p>
</div> </div>
<div class="hosts__scope"> <div class="hosts__scope">
<span>{{ context.regionSummary() }}</span> <span>{{ context.regionSummary() }}</span>
@@ -39,7 +53,7 @@ import { TopologyHost, TopologyTarget } from './topology.models';
</select> </select>
</div> </div>
<div class="filters__item"> <div class="filters__item">
<label for="hosts-status">Status</label> <label for="hosts-status">Host Status</label>
<select id="hosts-status" [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)"> <select id="hosts-status" [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)">
<option value="all">All</option> <option value="all">All</option>
<option value="healthy">Healthy</option> <option value="healthy">Healthy</option>
@@ -48,6 +62,14 @@ import { TopologyHost, TopologyTarget } from './topology.models';
<option value="unknown">Unknown</option> <option value="unknown">Unknown</option>
</select> </select>
</div> </div>
<div class="filters__item">
<label for="hosts-probe">Runtime Probe</label>
<select id="hosts-probe" [ngModel]="probeFilter()" (ngModelChange)="probeFilter.set($event)">
<option value="all">All</option>
<option value="monitored">Monitored</option>
<option value="unmonitored">Not monitored</option>
</select>
</div>
</section> </section>
@if (error()) { @if (error()) {
@@ -56,15 +78,31 @@ import { TopologyHost, TopologyTarget } from './topology.models';
@if (loading()) { @if (loading()) {
<div class="skeleton-table"> <div class="skeleton-table">
<div class="skeleton-row" style="width:100%"><div class="skeleton-line skeleton-line--title"></div></div> <div class="skeleton-row" style="width: 100%">
<div class="skeleton-row"><div class="skeleton-line"></div><div class="skeleton-line skeleton-line--short"></div></div> <div class="skeleton-line skeleton-line--title"></div>
<div class="skeleton-row"><div class="skeleton-line"></div><div class="skeleton-line skeleton-line--short"></div></div> </div>
<div class="skeleton-row"><div class="skeleton-line"></div><div class="skeleton-line skeleton-line--short"></div></div> <div class="skeleton-row">
<div class="skeleton-line"></div>
<div class="skeleton-line skeleton-line--short"></div>
</div>
<div class="skeleton-row">
<div class="skeleton-line"></div>
<div class="skeleton-line skeleton-line--short"></div>
</div>
<div class="skeleton-row">
<div class="skeleton-line"></div>
<div class="skeleton-line skeleton-line--short"></div>
</div>
</div> </div>
} @else { } @else {
<section class="split"> <section class="split">
<article class="card"> <article class="card">
<h2>Hosts</h2> <div class="card__header">
<div>
<h2>Hosts</h2>
<p>{{ monitoredHostsCount() }} monitored of {{ hosts().length }} in scope</p>
</div>
</div>
<div class="table-wrap"> <div class="table-wrap">
<table class="stella-table stella-table--hoverable"> <table class="stella-table stella-table--hoverable">
<thead> <thead>
@@ -74,24 +112,53 @@ import { TopologyHost, TopologyTarget } from './topology.models';
<th>Environment</th> <th>Environment</th>
<th>Runtime</th> <th>Runtime</th>
<th>Status</th> <th>Status</th>
<th>Runtime Probe</th>
<th>Last Seen</th>
<th>Targets</th> <th>Targets</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (host of filteredHosts(); track host.hostId) { @for (host of filteredHosts(); track host.hostId) {
<tr [class.active]="selectedHostId() === host.hostId" (click)="selectedHostId.set(host.hostId)"> <tr [class.active]="selectedHostId() === host.hostId" (click)="selectedHostId.set(host.hostId)">
<td class="cell-name">{{ host.hostName }}</td> <td class="cell-host">
<div class="cell-host__main">
<a [routerLink]="hostDetailLink(host.hostId)" class="cell-host__link">{{ host.hostName }}</a>
<span class="cell-host__meta">{{ host.hostId }}</span>
</div>
</td>
<td>{{ host.regionId }}</td> <td>{{ host.regionId }}</td>
<td>{{ host.environmentId }}</td> <td>{{ host.environmentId }}</td>
<td>{{ host.runtimeType }}</td> <td>{{ host.runtimeType }}</td>
<td><span class="status-badge" [class.status-badge--healthy]="host.status.toLowerCase() === 'healthy'" [class.status-badge--degraded]="host.status.toLowerCase() === 'degraded'" [class.status-badge--unhealthy]="host.status.toLowerCase() === 'offline'" [class.status-badge--muted]="host.status.toLowerCase() === 'unknown'">{{ host.status }}</span></td> <td>
<app-status-badge
[status]="hostStatusTone(host.status)"
[label]="host.status"
[showIcon]="true"
size="sm"
/>
</td>
<td>
<div class="probe-cell" [title]="probeTooltip(host)">
<app-status-badge
[status]="probeTone(host)"
[label]="probeLabel(host)"
[showIcon]="true"
size="sm"
/>
@if (showProbeType(host)) {
<span class="probe-cell__type">{{ probeType(host) }}</span>
}
</div>
</td>
<td>{{ lastSeenLabel(host) }}</td>
<td>{{ host.targetCount }}</td> <td>{{ host.targetCount }}</td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="6" class="empty-cell"> <tr>
<svg class="empty-cell__icon" viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg> <td colspan="8" class="empty-cell">
No hosts for current filters. No hosts for current filters.
</td></tr> </td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -101,24 +168,52 @@ import { TopologyHost, TopologyTarget } from './topology.models';
<article class="card detail"> <article class="card detail">
<h2>Selected Host</h2> <h2>Selected Host</h2>
@if (selectedHost()) { @if (selectedHost()) {
<p class="detail__name"><strong>{{ selectedHost()!.hostName }}</strong></p> <div class="detail__hero">
<div class="detail__grid"> <div>
<span class="detail__label">Status</span><span class="detail__value"><span class="status-badge" [class.status-badge--healthy]="selectedHost()!.status.toLowerCase() === 'healthy'" [class.status-badge--degraded]="selectedHost()!.status.toLowerCase() === 'degraded'" [class.status-badge--unhealthy]="selectedHost()!.status.toLowerCase() === 'offline'" [class.status-badge--muted]="selectedHost()!.status.toLowerCase() === 'unknown'">{{ selectedHost()!.status }}</span></span> <p class="detail__name">{{ selectedHost()!.hostName }}</p>
<span class="detail__label">Runtime</span><span class="detail__value">{{ selectedHost()!.runtimeType }}</span> <p class="detail__meta">{{ selectedHost()!.regionId }} / {{ selectedHost()!.environmentId }}</p>
<span class="detail__label">Agent</span><span class="detail__value">{{ selectedHost()!.agentId }}</span> </div>
<span class="detail__label">Last seen</span><span class="detail__value">{{ selectedHost()!.lastSeenAt ?? '-' }}</span> <app-status-badge
<span class="detail__label">Impacted targets</span><span class="detail__value">{{ selectedHostTargets().length }}</span> [status]="probeTone(selectedHost()!)"
<span class="detail__label">Upgrade window</span><span class="detail__value">Fri 23:00 UTC</span> [label]="probeLabel(selectedHost()!)"
[showIcon]="true"
size="sm"
/>
</div> </div>
<div class="detail__grid">
<span class="detail__label">Runtime</span>
<span class="detail__value">{{ selectedHost()!.runtimeType }}</span>
<span class="detail__label">Host status</span>
<span class="detail__value">{{ selectedHost()!.status }}</span>
<span class="detail__label">Connection</span>
<span class="detail__value">{{ selectedConnectionProfile().connectionLabel }}</span>
<span class="detail__label">Endpoint</span>
<span class="detail__value">{{ selectedConnectionProfile().endpointLabel }}:{{ selectedConnectionProfile().portLabel }}</span>
<span class="detail__label">Probe</span>
<span class="detail__value">{{ probeType(selectedHost()!) }}</span>
<span class="detail__label">Last seen</span>
<span class="detail__value">{{ lastSeenLabel(selectedHost()!) }}</span>
<span class="detail__label">Mapped targets</span>
<span class="detail__value">{{ selectedHostTargets().length }}</span>
</div>
<p class="detail__summary">{{ selectedConnectionProfile().summary }}</p>
<div class="actions"> <div class="actions">
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ hostId: selectedHost()!.hostId }" queryParamsHandling="merge">Open Targets</a> <a [routerLink]="hostDetailLink(selectedHost()!.hostId)">Open Host Detail</a>
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ agentId: selectedHost()!.agentId }" queryParamsHandling="merge">Open Agent</a> <a [routerLink]="['/environments/targets']" [queryParams]="{ hostId: selectedHost()!.hostId }">Open Targets</a>
<a [routerLink]="['/ops/integrations']" queryParamsHandling="merge">Integrations</a> <a [routerLink]="['/ops/operations/agents']" [queryParams]="{ agentId: selectedHost()!.agentId }">Open Agent</a>
</div> </div>
} @else { } @else {
<div class="empty-state"> <div class="empty-state">
<svg class="empty-state__icon" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg> <span>Select a host row to inspect runtime coverage and mapped targets.</span>
<span>Select a host row to inspect runtime drift and impact.</span>
</div> </div>
} }
</article> </article>
@@ -168,7 +263,6 @@ import { TopologyHost, TopologyTarget } from './topology.models';
padding: 0.1rem 0.45rem; padding: 0.1rem 0.45rem;
} }
/* --- Filters --- */
.filters { .filters {
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -180,8 +274,15 @@ import { TopologyHost, TopologyTarget } from './topology.models';
flex-wrap: wrap; flex-wrap: wrap;
} }
.filters__item { display: grid; gap: 0.15rem; } .filters__item {
.filters__item--wide { flex: 1; min-width: 180px; } display: grid;
gap: 0.15rem;
}
.filters__item--wide {
flex: 1;
min-width: 180px;
}
.filters label { .filters label {
font-size: 0.67rem; font-size: 0.67rem;
@@ -209,7 +310,6 @@ import { TopologyHost, TopologyTarget } from './topology.models';
box-shadow: 0 0 0 2px var(--color-focus-ring); box-shadow: 0 0 0 2px var(--color-focus-ring);
} }
/* --- Banner --- */
.banner { .banner {
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -225,7 +325,6 @@ import { TopologyHost, TopologyTarget } from './topology.models';
border-color: var(--color-status-error-border); border-color: var(--color-status-error-border);
} }
/* --- Skeleton --- */
.skeleton-table { .skeleton-table {
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -235,30 +334,43 @@ import { TopologyHost, TopologyTarget } from './topology.models';
gap: 0.5rem; gap: 0.5rem;
} }
.skeleton-row { display: flex; gap: 0.75rem; } .skeleton-row {
display: flex;
gap: 0.75rem;
}
.skeleton-line { .skeleton-line {
flex: 1; flex: 1;
height: 0.65rem; height: 0.65rem;
border-radius: var(--radius-sm); 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%; background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite; animation: shimmer 1.5s ease-in-out infinite;
} }
.skeleton-line--title { height: 0.85rem; max-width: 30%; } .skeleton-line--title {
.skeleton-line--short { max-width: 40%; } height: 0.85rem;
max-width: 30%;
}
.skeleton-line--short {
max-width: 40%;
}
@keyframes shimmer { @keyframes shimmer {
0% { background-position: 200% 0; } 0% { background-position: 200% 0; }
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
} }
/* --- Layout --- */
.split { .split {
display: grid; display: grid;
gap: 0.6rem; gap: 0.6rem;
grid-template-columns: 1.45fr 1fr; grid-template-columns: 1.55fr 1fr;
align-items: start; align-items: start;
} }
@@ -268,12 +380,9 @@ import { TopologyHost, TopologyTarget } from './topology.models';
background: var(--color-surface-primary); background: var(--color-surface-primary);
padding: 0.7rem; padding: 0.7rem;
display: grid; display: grid;
gap: 0.4rem; gap: 0.5rem;
transition: box-shadow 180ms ease;
} }
.card:hover { box-shadow: var(--shadow-sm); }
.card h2 { .card h2 {
margin: 0; margin: 0;
font-size: 0.92rem; font-size: 0.92rem;
@@ -281,18 +390,30 @@ import { TopologyHost, TopologyTarget } from './topology.models';
font-weight: 600; 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 { .table-wrap {
max-height: 420px; max-height: 520px;
overflow: auto; overflow: auto;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
/* Table styling provided by global .stella-table class */
th { th {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
white-space: nowrap;
} }
tbody tr { tbody tr {
@@ -300,41 +421,77 @@ import { TopologyHost, TopologyTarget } from './topology.models';
transition: background 120ms ease; transition: background 120ms ease;
} }
tbody tr:nth-child(even) { background: var(--color-surface-primary); } tbody tr:hover {
tbody tr:hover { background: var(--color-brand-soft); } background: var(--color-brand-soft);
}
tbody tr.active { tbody tr.active {
background: var(--color-brand-primary-10); background: var(--color-brand-primary-10);
box-shadow: inset 3px 0 0 var(--color-brand-primary); 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 --- */ .cell-host__link {
.status-badge { 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; display: inline-flex;
align-items: center; align-items: center;
border-radius: var(--radius-full); gap: 0.35rem;
font-size: 0.67rem;
font-weight: 500;
padding: 0.1rem 0.4rem;
line-height: 1.3;
border: 1px solid transparent;
white-space: nowrap; 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); } .probe-cell__type {
.status-badge--degraded { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); border-color: var(--color-status-warning-border); } color: var(--color-text-muted);
.status-badge--unhealthy { background: var(--color-status-error-bg); color: var(--color-status-error-text); border-color: var(--color-status-error-border); } font-size: 0.68rem;
.status-badge--muted { background: var(--color-surface-tertiary); color: var(--color-text-muted); border-color: var(--color-border-primary); } font-weight: 500;
}
/* --- Detail panel --- */ .detail {
.detail__name { margin: 0; font-size: 0.82rem; color: var(--color-text-primary); } 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 { .detail__grid {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
gap: 0.2rem 0.6rem; gap: 0.25rem 0.6rem;
font-size: 0.74rem; font-size: 0.74rem;
} }
@@ -347,12 +504,22 @@ import { TopologyHost, TopologyTarget } from './topology.models';
padding-top: 0.1rem; 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 {
.actions { display: flex; flex-wrap: wrap; gap: 0.35rem; } display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.actions a { .actions a {
display: inline-flex; display: inline-flex;
@@ -372,39 +539,28 @@ import { TopologyHost, TopologyTarget } from './topology.models';
color: var(--color-text-link-hover); color: var(--color-text-link-hover);
} }
/* --- Empty states --- */ .empty-cell,
.empty-cell { .empty-state {
text-align: center; text-align: center;
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.74rem; font-size: 0.74rem;
padding: 1rem 0.5rem; padding: 1rem 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
} }
.empty-cell__icon { opacity: 0.4; flex-shrink: 0; } @media (max-width: 1080px) {
.split {
.empty-state { grid-template-columns: 1fr;
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;
} }
.empty-state__icon { opacity: 0.35; }
.muted { color: var(--color-text-secondary); font-size: 0.74rem; }
@media (max-width: 960px) { @media (max-width: 960px) {
.filters { flex-direction: column; } .filters {
.filters__item--wide { min-width: auto; } flex-direction: column;
.split { grid-template-columns: 1fr; } }
.filters__item--wide {
min-width: auto;
}
} }
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -412,45 +568,68 @@ import { TopologyHost, TopologyTarget } from './topology.models';
export class TopologyHostsPageComponent { export class TopologyHostsPageComponent {
private readonly topologyApi = inject(TopologyDataService); private readonly topologyApi = inject(TopologyDataService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly helperCtx = inject(StellaHelperContextService);
readonly context = inject(PlatformContextStore); readonly context = inject(PlatformContextStore);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly searchQuery = signal(''); readonly searchQuery = signal('');
readonly runtimeFilter = signal('all'); readonly runtimeFilter = signal('all');
readonly statusFilter = signal('all'); readonly statusFilter = signal<HostStatusFilter>('all');
readonly probeFilter = signal<ProbeFilter>('all');
readonly selectedHostId = signal(''); readonly selectedHostId = signal('');
readonly hosts = signal<TopologyHost[]>([]); readonly hosts = signal<TopologyHost[]>([]);
readonly targets = signal<TopologyTarget[]>([]); readonly targets = signal<TopologyTarget[]>([]);
readonly runtimeOptions = computed(() => 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(() => { readonly filteredHosts = computed(() => {
const query = this.searchQuery().trim().toLowerCase(); const query = this.searchQuery().trim().toLowerCase();
const runtime = this.runtimeFilter(); const runtime = this.runtimeFilter();
const status = this.statusFilter(); const status = this.statusFilter();
const probe = this.probeFilter();
return this.hosts().filter((item) => { return this.hosts().filter((item) => {
const matchesQuery = const matchesQuery =
!query || !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 matchesRuntime = runtime === 'all' || item.runtimeType === runtime;
const normalizedStatus = item.status.trim().toLowerCase(); const normalizedStatus = item.status.trim().toLowerCase();
const matchesStatus = status === 'all' || normalizedStatus === status; 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(() => { readonly selectedHost = computed(() => {
const selectedId = this.selectedHostId(); const selectedId = this.selectedHostId();
const filteredHosts = this.filteredHosts();
if (!selectedId) { 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(() => { readonly selectedHostTargets = computed(() => {
@@ -458,7 +637,34 @@ export class TopologyHostsPageComponent {
if (!host) { if (!host) {
return []; 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() { constructor() {
@@ -469,6 +675,7 @@ export class TopologyHostsPageComponent {
if (hostId) { if (hostId) {
this.selectedHostId.set(hostId); this.selectedHostId.set(hostId);
} }
const environment = params.get('environment'); const environment = params.get('environment');
if (environment) { if (environment) {
this.searchQuery.set(environment); this.searchQuery.set(environment);
@@ -479,6 +686,71 @@ export class TopologyHostsPageComponent {
this.context.contextVersion(); this.context.contextVersion();
this.load(); 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 { private load(): void {
@@ -494,9 +766,11 @@ export class TopologyHostsPageComponent {
next: ({ hosts, targets }) => { next: ({ hosts, targets }) => {
this.hosts.set(hosts); this.hosts.set(hosts);
this.targets.set(targets); this.targets.set(targets);
if (!this.selectedHostId() && hosts.length > 0) { if (!this.selectedHostId() && hosts.length > 0) {
this.selectedHostId.set(hosts[0].hostId); this.selectedHostId.set(hosts[0].hostId);
} }
this.loading.set(false); this.loading.set(false);
}, },
error: (err: unknown) => { error: (err: unknown) => {
@@ -508,5 +782,3 @@ export class TopologyHostsPageComponent {
}); });
} }
} }

View File

@@ -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<TopologyHost, 'probeStatus'> | 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<TopologyTarget, 'releaseVersionId'>[],
): string | null {
const counts = new Map<string, number>();
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<TopologyTarget, 'hostId' | 'releaseVersionId'>,
host: Pick<TopologyHost, 'hostName' | 'runtimeType' | 'probeStatus' | 'probeType' | 'probeLastHeartbeat' | 'lastSeenAt'> | 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,
};
}

View File

@@ -6,6 +6,9 @@ export interface PlatformListResponse<T> {
offset?: number; offset?: number;
} }
export type TopologyProbeStatus = 'active' | 'offline' | 'not_installed';
export type TopologyProbeType = 'ebpf' | 'etw' | 'dyld';
export interface TopologyRegion { export interface TopologyRegion {
regionId: string; regionId: string;
displayName: string; displayName: string;
@@ -57,6 +60,9 @@ export interface TopologyHost {
agentId: string; agentId: string;
targetCount: number; targetCount: number;
lastSeenAt: string | null; lastSeenAt: string | null;
probeStatus?: TopologyProbeStatus | null;
probeType?: TopologyProbeType | null;
probeLastHeartbeat?: string | null;
} }
export interface TopologyAgent { export interface TopologyAgent {
@@ -128,4 +134,3 @@ export interface ReadinessReport {
evaluatedAt: string; evaluatedAt: string;
} }