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,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 { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
@@ -25,11 +25,20 @@ import {
TopologyPromotionPath,
TopologyTarget,
} from './topology.models';
import {
buildRuntimeVerificationSummary,
dominantReleaseVersion,
runtimeVerificationTone,
shortDigest,
shortId,
type RuntimeVerificationStatus,
} from './topology-runtime.helpers';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component';
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
import { StatGroupComponent } from '../../shared/components/stat-card/stat-card.component';
import { RelativeTimePipe, DurationPipe } from '../../shared/pipes/format.pipes';
import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service';
type EnvironmentTab = 'overview' | 'targets' | 'readiness' | 'deployments' | 'agents' | 'security' | 'evidence' | 'drift' | 'data-quality';
@@ -70,9 +79,9 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
template: `
<section class="env-detail">
<!-- ── Header ── -->
<!-- Header -->
<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__title-row">
<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 (isFrozen()) { <app-status-badge status="error" label="FROZEN" [showIcon]="true" size="sm" /> }
</div>
<p class="hdr__sub">{{ regionLabel() }} · {{ environmentTypeLabel() }}</p>
<p class="hdr__sub">{{ regionLabel() }} | {{ environmentTypeLabel() }}</p>
@if (promotionLine()) { <p class="hdr__promo">{{ promotionLine() }}</p> }
</div>
<div class="hdr__actions">
<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>
<button class="btn btn--secondary btn--sm" (click)="refresh()">Refresh</button>
<a class="btn btn--primary btn--sm" [routerLink]="['/releases/promotions/create']" [queryParams]="{ targetEnvironmentId: environmentId() }">Request Promotion</a>
</div>
</header>
@@ -104,7 +113,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
} @else {
@switch (activeTab()) {
<!-- ════════ OVERVIEW ════════ -->
<!-- Overview -->
@case ('overview') {
<div class="overview-layout">
<div class="overview-main">
@@ -135,7 +144,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
<article class="panel">
<div class="panel__hdr">
<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 class="readiness-mini">
<span class="rm rm--ok">{{ readyTargetsCnt() }} pass</span>
@@ -170,30 +179,67 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
</div>
}
<!-- ════════ TARGETS ════════ -->
<!-- Targets -->
@case ('targets') {
<article class="panel">
<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>Last Sync</th></tr></thead>
<tbody>
@for (t of targetRows(); track t.targetId) {
<tr>
<td>{{ t.name }}</td>
<td>{{ t.targetType }}</td>
<td>{{ hostName(t.hostId) }}</td>
<td>{{ agentName(t.agentId) }}</td>
<td><app-status-badge [status]="healthToStatus(t.healthStatus)" [label]="t.healthStatus" [showIcon]="true" size="sm" /></td>
<td>{{ t.lastSyncAt | relativeTime }}</td>
</tr>
} @empty {
<tr><td colspan="6" class="muted">No targets in this environment.</td></tr>
}
</tbody>
</table>
</article>
<app-stat-group columns="4">
<app-metric-card label="Verified" [value]="runtimeVerifiedCount()" [severity]="runtimeVerifiedCount() === targetRows().length && targetRows().length > 0 ? 'healthy' : 'warning'" />
<app-metric-card label="Drift" [value]="runtimeDriftCount()" [severity]="runtimeDriftCount() > 0 ? 'warning' : 'healthy'" />
<app-metric-card label="Probe Offline" [value]="runtimeOfflineCount()" [severity]="runtimeOfflineCount() > 0 ? 'critical' : 'healthy'" />
<app-metric-card label="Not Monitored" [value]="runtimeUnmonitoredCount()" [severity]="runtimeUnmonitoredCount() > 0 ? 'warning' : 'healthy'" />
</app-stat-group>
@if (targetRows().length === 0) {
<article class="panel panel--empty">
<div class="empty-panel">
<div class="empty-panel__icon" aria-hidden="true">O</div>
<div class="empty-panel__body">
<h3>No targets are mapped to this environment yet</h3>
<p>
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.
</p>
</div>
<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') {
<app-stat-group columns="3">
<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-stat-group>
<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>
} @empty {
<tr><td [attr.colspan]="gateNames.length + 3" class="muted">No readiness data. Run validation to check targets.</td></tr>
@if (readinessReports().length === 0) {
<article class="panel panel--empty">
<div class="empty-panel">
<div class="empty-panel__icon" aria-hidden="true">OK</div>
<div class="empty-panel__body">
<h3>No readiness checks have been captured yet</h3>
<p>
Readiness records show whether agents are bound, registries are reachable, and connectivity gates are passing for each target.
@if (targetRows().length === 0) {
Add targets to this environment first, then Stella can validate promotion readiness.
} @else {
Run validation once to populate the gate matrix before reviewing or promoting this environment.
}
</p>
</div>
<div class="empty-panel__actions">
@if (targetRows().length === 0) {
<a class="btn btn--primary btn--sm" [routerLink]="['/environments/targets']" [queryParams]="{ environment: environmentId() }">Review targets</a>
<a class="btn btn--secondary btn--sm" [routerLink]="['/ops/integrations']">Open integrations</a>
} @else {
<button class="btn btn--primary btn--sm" (click)="validateAll()" [disabled]="validatingAll()">
{{ validatingAll() ? 'Validating...' : 'Validate All' }}
</button>
<button class="btn btn--secondary btn--sm" (click)="activeTab.set('targets')">Inspect targets</button>
}
</tbody>
</table>
</div>
</article>
</div>
</div>
</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') {
<article class="panel">
<table class="stella-table stella-table--striped stella-table--hoverable">
@@ -253,9 +327,9 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
<tr>
<td>{{ run.releaseName }}</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><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>
} @empty {
<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>
}
<!-- ════════ AGENTS ════════ -->
<!-- Agents -->
@case ('agents') {
<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>
} @empty {
<tr><td colspan="5" class="muted">No agents in this environment.</td></tr>
}
</tbody>
</table>
</article>
@if (agentRows().length === 0) {
<article class="panel panel--empty">
<div class="empty-panel">
<div class="empty-panel__icon" aria-hidden="true">CFG</div>
<div class="empty-panel__body">
<h3>No agents are reporting for this environment</h3>
<p>
Agents are what let Stella validate readiness and execute deployments on real hosts.
Until an agent is connected here, this environment can describe topology but cannot perform release work.
</p>
</div>
<div class="empty-panel__actions">
<a class="btn btn--primary btn--sm" [routerLink]="['/ops/operations/agents']">Open agent fleet</a>
<a class="btn btn--secondary btn--sm" [routerLink]="['/ops/operations/doctor']">Run diagnostics</a>
</div>
</div>
</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') {
<article class="panel">
<table class="stella-table stella-table--striped stella-table--hoverable">
@@ -297,14 +388,14 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
<tr>
<td class="mono">{{ f.cveId }}</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>
@if (f.reachable != null) {
<app-status-badge [status]="f.reachable ? 'error' : 'neutral'" [label]="f.reachable ? 'Yes' : 'No'" size="sm" />
} @else { }
} @else { - }
</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>
} @empty {
<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>
}
<!-- ════════ EVIDENCE ════════ -->
<!-- Evidence -->
@case ('evidence') {
@if (capsuleRows().length === 0) {
<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') {
<article class="panel">
@if (driftDetected()) {
@@ -365,9 +456,74 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
</div>
}
</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') {
<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" />
@@ -383,7 +539,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
styles: [`
.env-detail { display: grid; gap: 0.75rem; }
/* ── Header ── */
/* Header */
.hdr { display: grid; gap: 0.25rem; padding: 0.6rem 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); }
.hdr__back { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); text-decoration: none; }
.hdr__back:hover { text-decoration: underline; }
@@ -395,7 +551,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
.hdr__actions { display: flex; gap: 0.35rem; align-items: center; justify-content: flex-end; }
.chip--release { font-size: 0.66rem; padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); background: var(--color-surface-secondary); color: var(--color-text-secondary); font-family: var(--font-mono, monospace); }
/* ── Buttons ── */
/* Buttons */
.btn { padding: 0.3rem 0.65rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.74rem; font-weight: 500; border: none; text-decoration: none; display: inline-flex; align-items: center; gap: 0.2rem; transition: background 150ms ease; }
.btn--sm { padding: 0.28rem 0.6rem; }
.btn--xs { padding: 0.2rem 0.4rem; font-size: 0.68rem; }
@@ -409,33 +565,54 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
.link:hover { text-decoration: underline; }
.mono { font-family: var(--font-mono, monospace); font-size: 0.72rem; }
/* ── Panels ── */
/* Panels */
.panel { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.7rem; display: grid; gap: 0.4rem; }
.panel--empty { padding: 0.85rem; }
.panel h2 { margin: 0; font-size: 0.88rem; font-weight: 600; }
.panel__hdr { display: flex; justify-content: space-between; align-items: center; }
.panel__link { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); cursor: pointer; text-decoration: none; }
.panel__link:hover { text-decoration: underline; }
.panel__ok { margin: 0; color: var(--color-status-success, #22c55e); font-size: 0.76rem; }
.empty-panel { display: grid; gap: 0.75rem; align-items: start; }
.empty-panel__icon {
width: 2rem;
height: 2rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-brand-soft, rgba(59,130,246,0.12)) 75%, transparent);
color: var(--color-text-link, var(--color-brand, #3b82f6));
font-size: 1rem;
font-weight: 700;
}
.empty-panel__body { display: grid; gap: 0.35rem; }
.empty-panel__body h3 { margin: 0; font-size: 0.9rem; font-weight: 600; }
.empty-panel__body p { margin: 0; color: var(--color-text-secondary); font-size: 0.76rem; line-height: 1.5; }
.empty-panel__actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.banner { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.5rem 0.65rem; font-size: 0.78rem; }
.banner--error { color: var(--color-status-error-text, #ef4444); background: var(--color-status-error-bg, rgba(239,68,68,0.06)); }
.muted { color: var(--color-text-muted); font-size: 0.74rem; }
.runtime-note { margin: 0; color: var(--color-text-secondary); font-size: 0.74rem; line-height: 1.45; }
.runtime-cell { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 0.15rem; }
.runtime-caption { color: var(--color-text-muted); font-size: 0.64rem; }
/* ── Overview 2-column layout ── */
/* Overview 2-column layout */
.overview-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 0.65rem; }
.overview-main { display: grid; gap: 0.65rem; }
.overview-side { display: grid; gap: 0.65rem; align-content: start; }
/* ── Blocker list ── */
/* Blocker list */
.blocker-list { display: grid; gap: 0.3rem; }
.blocker-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.76rem; }
/* ── Readiness mini ── */
/* Readiness mini */
.readiness-mini { display: flex; gap: 0.75rem; font-size: 0.78rem; font-weight: 600; }
.rm--ok { color: var(--color-status-success, #22c55e); }
.rm--fail { color: var(--color-status-error, #ef4444); }
.rm--pend { color: var(--color-status-warning, #f59e0b); }
/* ── Health circle ── */
/* Health circle */
.health-circle-panel { display: flex; justify-content: center; padding: 1rem; }
.health-circle {
width: 80px; height: 80px; border-radius: 50%;
@@ -448,18 +625,18 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
.health-circle__num { font-size: 1.1rem; font-weight: 700; line-height: 1; }
.health-circle__label { font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
/* ── Quick stats ── */
/* Quick stats */
.quick-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.35rem; text-align: center; }
.qs { display: flex; flex-direction: column; align-items: center; }
.qs__v { font-size: 1rem; font-weight: 700; color: var(--color-text-heading); }
.qs__l { font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
/* ── Gate grid ── */
/* Gate grid */
.gg-wrap { overflow-x: auto; }
.th-gate, .td-gate { text-align: center; font-size: 0.68rem; }
.th-gate { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; }
/* ── Evidence grid ── */
/* Evidence grid */
.evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.5rem; }
.ev-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.55rem; display: grid; gap: 0.3rem; }
.ev-card__hdr { display: flex; justify-content: space-between; align-items: center; }
@@ -470,12 +647,30 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
.ev-card__date { font-size: 0.66rem; color: var(--color-text-muted); }
.ev-card__foot { display: flex; gap: 0.3rem; }
/* ── Drift ── */
/* Drift */
.drift-alert { padding: 0.5rem; background: var(--color-status-warning-bg, rgba(245,158,11,0.08)); border-radius: var(--radius-sm); }
.drift-alert__msg { display: flex; align-items: flex-start; gap: 0.5rem; }
.drift-alert__msg p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); }
.drift-ok { display: flex; align-items: center; gap: 0.5rem; }
.drift-ok p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); }
.runtime-summary { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.runtime-summary__item {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
padding: 0.12rem 0.45rem;
font-size: 0.68rem;
font-weight: 600;
}
.runtime-summary__item--verified { background: var(--color-status-success-bg, rgba(34,197,94,0.08)); color: var(--color-status-success-text, #15803d); }
.runtime-summary__item--drift { background: var(--color-status-warning-bg, rgba(245,158,11,0.1)); color: var(--color-status-warning-text, #b45309); }
.runtime-summary__item--offline { background: var(--color-status-error-bg, rgba(239,68,68,0.08)); color: var(--color-status-error-text, #b91c1c); }
.runtime-summary__item--unmonitored { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
.runtime-matrix-wrap { overflow-x: auto; }
.runtime-row--drift { background: var(--color-status-warning-bg, rgba(245,158,11,0.1)); }
.runtime-row--offline { background: var(--color-status-error-bg, rgba(239,68,68,0.08)); }
.runtime-row--unmonitored { background: var(--color-surface-secondary); }
@media (max-width: 1024px) {
.overview-layout { grid-template-columns: 1fr; }
@@ -488,6 +683,8 @@ export class TopologyEnvironmentDetailPageComponent {
private readonly topologySetup = inject(TopologySetupClient);
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly helperCtx = inject(StellaHelperContextService);
readonly context = inject(PlatformContextStore);
readonly gateNames = GATE_NAMES;
@@ -514,20 +711,53 @@ export class TopologyEnvironmentDetailPageComponent {
readonly currentRelease = signal<string | null>(null);
readonly validating = signal<Set<string>>(new Set());
readonly validatingAll = signal(false);
readonly runtimeSectionExpanded = signal(true);
// ── Lookup maps ──
// Lookup maps
readonly hostMap = computed(() => new Map(this.hostRows().map(h => [h.hostId, h])));
readonly hostNameMap = computed(() => new Map(this.hostRows().map(h => [h.hostId, h.hostName])));
readonly agentNameMap = computed(() => new Map(this.agentRows().map(a => [a.agentId, a.agentName])));
readonly targetNameMap = computed(() => new Map(this.targetRows().map(t => [t.targetId, t.name])));
readonly envNameMap = computed(() => new Map(this.allEnvironments().map(e => [e.environmentId, e.displayName])));
// ── Health computeds ──
// Health computeds
readonly healthyTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'healthy').length);
readonly degradedTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'degraded').length);
readonly unhealthyTargets = computed(() => this.targetRows().filter(t => { const s = t.healthStatus.trim().toLowerCase(); return s === 'unhealthy' || s === 'offline' || s === 'unknown'; }).length);
readonly unknownHealthTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'unknown').length);
readonly blockingFindings = computed(() => this.findingRows().filter(f => f.effectiveDisposition.trim().toLowerCase() === 'action_required').length);
readonly staleCapsules = computed(() => this.capsuleRows().filter(c => c.status.trim().toLowerCase().includes('stale')).length);
readonly degradedAgents = computed(() => this.agentRows().filter(a => a.status.trim().toLowerCase() !== 'active').length);
readonly helperContexts = computed(() => {
const contexts: string[] = [];
if (this.blockingFindings() > 0) {
contexts.push('critical-open');
}
if (this.failingTargetsCnt() > 0) {
contexts.push('gate-blocked');
}
if (this.unknownHealthTargets() > 0) {
contexts.push('health-unknown');
}
if (this.degradedAgents() > 0) {
contexts.push('agents-degraded');
}
if (!this.loading() && this.agentRows().length === 0) {
contexts.push('agents-none');
}
if (!this.loading()) {
if (this.activeTab() === 'targets' && this.targetRows().length === 0) {
contexts.push('empty-table');
}
if (this.activeTab() === 'readiness' && this.readinessReports().length === 0) {
contexts.push('empty-table');
}
if (this.activeTab() === 'agents' && this.agentRows().length === 0) {
contexts.push('empty-table');
}
}
return contexts;
});
readonly deployHealth = computed(() => {
if (this.unhealthyTargets() > 0) return 'UNHEALTHY';
@@ -540,12 +770,12 @@ export class TopologyEnvironmentDetailPageComponent {
return h === 'HEALTHY' ? 'success' : h === 'DEGRADED' ? 'warning' : 'error';
});
// ── Readiness computeds ──
// Readiness computeds
readonly readyTargetsCnt = computed(() => this.readinessReports().filter(r => r.isReady).length);
readonly failingTargetsCnt = computed(() => this.readinessReports().filter(r => !r.isReady && r.gates.some(g => g.status === 'fail')).length);
readonly pendingTargetsCnt = computed(() => this.readinessReports().filter(r => !r.isReady && r.gates.some(g => g.status === 'pending') && !r.gates.some(g => g.status === 'fail')).length);
// ── Drift ──
// Drift
readonly driftDetected = computed(() => {
const targets = this.targetRows();
if (targets.length < 2) return false;
@@ -562,7 +792,30 @@ export class TopologyEnvironmentDetailPageComponent {
return targets.length - maxCount;
});
// ── Promotion context ──
// Promotion context
readonly expectedReleaseVersion = computed(() => dominantReleaseVersion(this.targetRows()));
readonly runtimeVerificationRows = computed(() =>
this.targetRows().map((target) => ({
target,
summary: buildRuntimeVerificationSummary(
target,
this.hostMap().get(target.hostId),
this.expectedReleaseVersion(),
),
})),
);
readonly runtimeVerifiedCount = computed(() =>
this.runtimeVerificationRows().filter(row => row.summary.status === 'verified').length,
);
readonly runtimeDriftCount = computed(() =>
this.runtimeVerificationRows().filter(row => row.summary.status === 'drift').length,
);
readonly runtimeOfflineCount = computed(() =>
this.runtimeVerificationRows().filter(row => row.summary.status === 'offline').length,
);
readonly runtimeUnmonitoredCount = computed(() =>
this.runtimeVerificationRows().filter(row => row.summary.status === 'not_monitored').length,
);
readonly promotionLine = computed(() => {
const envId = this.environmentId();
const paths = this.promotionPaths();
@@ -571,13 +824,13 @@ export class TopologyEnvironmentDetailPageComponent {
const upstream = paths.filter(p => p.targetEnvironmentId === envId).map(p => names.get(p.sourceEnvironmentId) ?? p.sourceEnvironmentId);
const downstream = paths.filter(p => p.sourceEnvironmentId === envId).map(p => names.get(p.targetEnvironmentId) ?? p.targetEnvironmentId);
const parts: string[] = [];
if (upstream.length) parts.push(upstream.join(', ') + ' ');
if (upstream.length) parts.push(upstream.join(', ') + ' ->');
parts.push(`[${this.environmentLabel()}]`);
if (downstream.length) parts.push(' ' + downstream.join(', '));
if (downstream.length) parts.push('-> ' + downstream.join(', '));
return parts.join(' ');
});
// ── Blockers ──
// Blockers
readonly blockers = computed(() => {
const items: { severity: 'error' | 'warning'; text: string }[] = [];
if (this.unhealthyTargets() > 0) items.push({ severity: 'error', text: `${this.unhealthyTargets()} unhealthy target(s) require runtime remediation.` });
@@ -588,7 +841,7 @@ export class TopologyEnvironmentDetailPageComponent {
return items;
});
// ── Tabs (dynamic status dots + badges) ──
// Tabs (dynamic status dots + badges)
readonly tabDefs = computed((): StellaPageTab[] => [
{ id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', status: this.deployHealthStatus() === 'success' ? 'ok' : this.deployHealthStatus() === 'warning' ? 'warn' : 'error' },
{ id: 'targets', label: 'Targets', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01', badge: this.targetRows().length || undefined },
@@ -607,13 +860,16 @@ export class TopologyEnvironmentDetailPageComponent {
{ label: 'Runs', route: '/releases/deployments', description: 'Deployment runs' },
]);
// ── Helpers ──
// Helpers
hostName(id: string): string { return this.hostNameMap().get(id) ?? id.substring(0, 8) + '...'; }
agentName(id: string): string { return this.agentNameMap().get(id) ?? id.substring(0, 8) + '...'; }
targetName(id: string): string { return this.targetNameMap().get(id) ?? id.substring(0, 12); }
healthToStatus(h: string): 'success' | 'warning' | 'error' | 'neutral' { return healthToStatus(h); }
severityStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { return severityToStatus(s); }
fmtGate(g: string): string { return fmtGateName(g); }
runtimeStatusTone(status: RuntimeVerificationStatus): 'success' | 'warning' | 'error' | 'neutral' { return runtimeVerificationTone(status); }
shortId(value: string | null | undefined): string { return shortId(value); }
shortDigest(value: string | null | undefined): string { return shortDigest(value); }
gateStatus(rpt: ReadinessReport, gateName: string): 'success' | 'error' | 'warning' | 'neutral' {
const gate = rpt.gates.find(x => x.gateName === gateName);
@@ -622,8 +878,8 @@ export class TopologyEnvironmentDetailPageComponent {
gateLabel(rpt: ReadinessReport, gateName: string): string {
const gate = rpt.gates.find(x => x.gateName === gateName);
if (!gate) return '';
switch (gate.status) { case 'pass': return ''; case 'fail': return ''; case 'pending': return ''; default: return ''; }
if (!gate) return '-';
switch (gate.status) { case 'pass': return 'OK'; case 'fail': return 'Fail'; case 'pending': return 'Pending'; default: return '-'; }
}
runStatusType(status: string): 'success' | 'warning' | 'error' | 'neutral' {
@@ -634,10 +890,14 @@ export class TopologyEnvironmentDetailPageComponent {
return 'neutral';
}
// ── Lifecycle ──
// Lifecycle
constructor() {
this.context.initialize();
effect(() => {
this.helperCtx.setScope('topology-environment-detail', this.helperContexts());
}, { allowSignalWrites: true });
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('topology-environment-detail'));
this.route.paramMap.subscribe(params => {
const id = params.get('environmentId') ?? '';
this.environmentId.set(id);
@@ -697,8 +957,14 @@ export class TopologyEnvironmentDetailPageComponent {
let best = ''; let bestCount = 0;
for (const [v, c] of counts) if (c > bestCount) { best = v; bestCount = c; }
this.currentRelease.set(best.substring(0, 12));
} else {
this.currentRelease.set(null);
}
this.runtimeSectionExpanded.set(
this.driftDetected() || this.runtimeOfflineCount() > 0 || this.runtimeUnmonitoredCount() > 0,
);
this.loading.set(false);
},
error: (err: unknown) => {
@@ -708,7 +974,7 @@ export class TopologyEnvironmentDetailPageComponent {
});
}
// ── Actions ──
// Actions
validateTarget(targetId: string): void {
const s = new Set(this.validating()); s.add(targetId); this.validating.set(s);

View File

@@ -1,22 +1,698 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { RelativeTimePipe } from '../../shared/pipes/format.pipes';
import { CopyToClipboardComponent } from '../../shared/ui/copy-to-clipboard/copy-to-clipboard.component';
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
import { TopologyDataService } from './topology-data.service';
import { TopologyHost, TopologyTarget } from './topology.models';
import {
dominantReleaseVersion,
hasRuntimeProbe,
HostConnectionProfile,
inferConnectionProfile,
inferProbeRecommendation,
normalizeProbeStatus,
probeStatusLabel,
probeStatusTone,
probeTypeLabel,
shortDigest,
shortId,
} from './topology-runtime.helpers';
interface HostActivityRow {
targetId: string;
targetName: string;
lastSyncAt: string | null;
releaseVersionId: string | null;
imageDigest: string | null;
drifted: boolean;
}
@Component({
selector: 'app-topology-host-detail-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, RelativeTimePipe, CopyToClipboardComponent, StatusBadgeComponent],
template: `
<section class="topology-page">
<header>
<h1>Host {{ hostId }}</h1>
<p>Inventory, connectivity tests, mapped targets, and agent diagnostics.</p>
<section class="host-detail">
<header class="hero">
<div class="hero__nav">
<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>
@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>
`,
styles: [`
.host-detail {
display: grid;
gap: 0.75rem;
}
.hero {
display: grid;
gap: 0.45rem;
padding: 0.8rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.hero__nav {
display: flex;
justify-content: flex-start;
}
.back-link {
color: var(--color-text-link);
text-decoration: none;
font-size: 0.74rem;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.hero__main {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
flex-wrap: wrap;
}
.hero__title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.hero__title-row h1 {
margin: 0;
font-size: 1.2rem;
color: var(--color-text-heading);
}
.hero__subtitle {
margin: 0.15rem 0 0;
color: var(--color-text-secondary);
font-size: 0.78rem;
}
.hero__chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.68rem;
padding: 0.12rem 0.45rem;
white-space: nowrap;
}
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 0.55rem 0.7rem;
font-size: 0.78rem;
}
.banner--error {
color: var(--color-status-error-text);
background: var(--color-status-error-bg);
border-color: var(--color-status-error-border);
}
.loading,
.empty-state {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 1rem;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.layout {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.75rem;
display: grid;
gap: 0.55rem;
align-content: start;
}
.card--wide {
grid-column: 1 / -1;
}
.card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.card h2 {
margin: 0;
font-size: 0.92rem;
color: var(--color-card-heading);
font-weight: 600;
}
.card__header p {
margin: 0.15rem 0 0;
color: var(--color-text-secondary);
font-size: 0.74rem;
}
.meta-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.7rem;
}
.meta-grid__label {
color: var(--color-text-muted);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
padding-top: 0.1rem;
}
.meta-grid__value {
color: var(--color-text-secondary);
font-size: 0.76rem;
line-height: 1.45;
}
.summary {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.74rem;
line-height: 1.5;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.actions a,
.link {
color: var(--color-text-link);
text-decoration: none;
font-size: 0.73rem;
font-weight: 500;
}
.actions a:hover,
.link:hover {
text-decoration: underline;
}
.probe-empty {
display: grid;
gap: 0.65rem;
}
.probe-empty p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.76rem;
}
.toggle {
display: flex;
gap: 0.45rem;
align-items: center;
color: var(--color-text-primary);
font-size: 0.74rem;
}
.command-block {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.45rem;
align-items: start;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
padding: 0.55rem;
}
.command-block code,
.mono {
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
word-break: break-all;
}
.activity-row--drift {
background: var(--color-status-warning-bg, rgba(245, 158, 11, 0.1));
}
.muted {
color: var(--color-text-muted);
font-size: 0.74rem;
}
table {
width: 100%;
}
@media (max-width: 1080px) {
.layout {
grid-template-columns: 1fr;
}
.card--wide {
grid-column: auto;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopologyHostDetailPageComponent {
private readonly topologyApi = inject(TopologyDataService);
private readonly route = inject(ActivatedRoute);
readonly hostId = this.route.snapshot.paramMap.get('hostId') ?? 'unknown';
readonly context = inject(PlatformContextStore);
readonly hostId = signal('');
readonly loading = signal(true);
readonly error = signal<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 { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service';
import { RelativeTimePipe } from '../../shared/pipes/format.pipes';
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
import { TopologyDataService } from './topology-data.service';
import { TopologyHost, TopologyTarget } from './topology.models';
import {
hasRuntimeProbe,
inferConnectionProfile,
normalizeProbeStatus,
probeStatusLabel,
probeStatusTone,
probeTypeLabel,
} from './topology-runtime.helpers';
type HostStatusFilter = 'all' | 'healthy' | 'degraded' | 'offline' | 'unknown';
type ProbeFilter = 'all' | 'monitored' | 'unmonitored';
@Component({
selector: 'app-topology-hosts-page',
standalone: true,
imports: [FormsModule, RouterLink],
imports: [FormsModule, RouterLink, StatusBadgeComponent],
template: `
<section class="hosts">
<header class="hosts__header">
<div>
<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 class="hosts__scope">
<span>{{ context.regionSummary() }}</span>
@@ -39,7 +53,7 @@ import { TopologyHost, TopologyTarget } from './topology.models';
</select>
</div>
<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)">
<option value="all">All</option>
<option value="healthy">Healthy</option>
@@ -48,6 +62,14 @@ import { TopologyHost, TopologyTarget } from './topology.models';
<option value="unknown">Unknown</option>
</select>
</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>
@if (error()) {
@@ -56,15 +78,31 @@ import { TopologyHost, TopologyTarget } from './topology.models';
@if (loading()) {
<div class="skeleton-table">
<div class="skeleton-row" style="width:100%"><div class="skeleton-line skeleton-line--title"></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" style="width: 100%">
<div class="skeleton-line skeleton-line--title"></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>
} @else {
<section class="split">
<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">
<table class="stella-table stella-table--hoverable">
<thead>
@@ -74,24 +112,53 @@ import { TopologyHost, TopologyTarget } from './topology.models';
<th>Environment</th>
<th>Runtime</th>
<th>Status</th>
<th>Runtime Probe</th>
<th>Last Seen</th>
<th>Targets</th>
</tr>
</thead>
<tbody>
@for (host of filteredHosts(); track 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.environmentId }}</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>
</tr>
} @empty {
<tr><td colspan="6" class="empty-cell">
<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>
No hosts for current filters.
</td></tr>
<tr>
<td colspan="8" class="empty-cell">
No hosts for current filters.
</td>
</tr>
}
</tbody>
</table>
@@ -101,24 +168,52 @@ import { TopologyHost, TopologyTarget } from './topology.models';
<article class="card detail">
<h2>Selected Host</h2>
@if (selectedHost()) {
<p class="detail__name"><strong>{{ selectedHost()!.hostName }}</strong></p>
<div class="detail__grid">
<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>
<span class="detail__label">Runtime</span><span class="detail__value">{{ selectedHost()!.runtimeType }}</span>
<span class="detail__label">Agent</span><span class="detail__value">{{ selectedHost()!.agentId }}</span>
<span class="detail__label">Last seen</span><span class="detail__value">{{ selectedHost()!.lastSeenAt ?? '-' }}</span>
<span class="detail__label">Impacted targets</span><span class="detail__value">{{ selectedHostTargets().length }}</span>
<span class="detail__label">Upgrade window</span><span class="detail__value">Fri 23:00 UTC</span>
<div class="detail__hero">
<div>
<p class="detail__name">{{ selectedHost()!.hostName }}</p>
<p class="detail__meta">{{ selectedHost()!.regionId }} / {{ selectedHost()!.environmentId }}</p>
</div>
<app-status-badge
[status]="probeTone(selectedHost()!)"
[label]="probeLabel(selectedHost()!)"
[showIcon]="true"
size="sm"
/>
</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">
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ hostId: selectedHost()!.hostId }" queryParamsHandling="merge">Open Targets</a>
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ agentId: selectedHost()!.agentId }" queryParamsHandling="merge">Open Agent</a>
<a [routerLink]="['/ops/integrations']" queryParamsHandling="merge">Integrations</a>
<a [routerLink]="hostDetailLink(selectedHost()!.hostId)">Open Host Detail</a>
<a [routerLink]="['/environments/targets']" [queryParams]="{ hostId: selectedHost()!.hostId }">Open Targets</a>
<a [routerLink]="['/ops/operations/agents']" [queryParams]="{ agentId: selectedHost()!.agentId }">Open Agent</a>
</div>
} @else {
<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 drift and impact.</span>
<span>Select a host row to inspect runtime coverage and mapped targets.</span>
</div>
}
</article>
@@ -168,7 +263,6 @@ import { TopologyHost, TopologyTarget } from './topology.models';
padding: 0.1rem 0.45rem;
}
/* --- Filters --- */
.filters {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
@@ -180,8 +274,15 @@ import { TopologyHost, TopologyTarget } from './topology.models';
flex-wrap: wrap;
}
.filters__item { display: grid; gap: 0.15rem; }
.filters__item--wide { flex: 1; min-width: 180px; }
.filters__item {
display: grid;
gap: 0.15rem;
}
.filters__item--wide {
flex: 1;
min-width: 180px;
}
.filters label {
font-size: 0.67rem;
@@ -209,7 +310,6 @@ import { TopologyHost, TopologyTarget } from './topology.models';
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
/* --- Banner --- */
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
@@ -225,7 +325,6 @@ import { TopologyHost, TopologyTarget } from './topology.models';
border-color: var(--color-status-error-border);
}
/* --- Skeleton --- */
.skeleton-table {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
@@ -235,30 +334,43 @@ import { TopologyHost, TopologyTarget } from './topology.models';
gap: 0.5rem;
}
.skeleton-row { display: flex; gap: 0.75rem; }
.skeleton-row {
display: flex;
gap: 0.75rem;
}
.skeleton-line {
flex: 1;
height: 0.65rem;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--color-skeleton-base) 25%, var(--color-skeleton-highlight) 50%, var(--color-skeleton-base) 75%);
background: linear-gradient(
90deg,
var(--color-skeleton-base) 25%,
var(--color-skeleton-highlight) 50%,
var(--color-skeleton-base) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-line--title { height: 0.85rem; max-width: 30%; }
.skeleton-line--short { max-width: 40%; }
.skeleton-line--title {
height: 0.85rem;
max-width: 30%;
}
.skeleton-line--short {
max-width: 40%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* --- Layout --- */
.split {
display: grid;
gap: 0.6rem;
grid-template-columns: 1.45fr 1fr;
grid-template-columns: 1.55fr 1fr;
align-items: start;
}
@@ -268,12 +380,9 @@ import { TopologyHost, TopologyTarget } from './topology.models';
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.4rem;
transition: box-shadow 180ms ease;
gap: 0.5rem;
}
.card:hover { box-shadow: var(--shadow-sm); }
.card h2 {
margin: 0;
font-size: 0.92rem;
@@ -281,18 +390,30 @@ import { TopologyHost, TopologyTarget } from './topology.models';
font-weight: 600;
}
/* --- Table --- */
.card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.card__header p {
margin: 0.15rem 0 0;
color: var(--color-text-secondary);
font-size: 0.74rem;
}
.table-wrap {
max-height: 420px;
max-height: 520px;
overflow: auto;
border-radius: var(--radius-sm);
}
/* Table styling provided by global .stella-table class */
th {
position: sticky;
top: 0;
z-index: 1;
white-space: nowrap;
}
tbody tr {
@@ -300,41 +421,77 @@ import { TopologyHost, TopologyTarget } from './topology.models';
transition: background 120ms ease;
}
tbody tr:nth-child(even) { background: var(--color-surface-primary); }
tbody tr:hover { background: var(--color-brand-soft); }
tbody tr:hover {
background: var(--color-brand-soft);
}
tbody tr.active {
background: var(--color-brand-primary-10);
box-shadow: inset 3px 0 0 var(--color-brand-primary);
}
.cell-name { font-weight: 500; }
.cell-host__main {
display: grid;
gap: 0.12rem;
}
/* --- Status badges --- */
.status-badge {
.cell-host__link {
color: var(--color-text-link);
text-decoration: none;
font-weight: 600;
}
.cell-host__link:hover {
text-decoration: underline;
}
.cell-host__meta {
color: var(--color-text-muted);
font-size: 0.68rem;
font-family: var(--font-mono, monospace);
}
.probe-cell {
display: inline-flex;
align-items: center;
border-radius: var(--radius-full);
font-size: 0.67rem;
font-weight: 500;
padding: 0.1rem 0.4rem;
line-height: 1.3;
border: 1px solid transparent;
gap: 0.35rem;
white-space: nowrap;
}
.status-badge--healthy { background: var(--color-status-success-bg); color: var(--color-status-success-text); border-color: var(--color-status-success-border); }
.status-badge--degraded { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); border-color: var(--color-status-warning-border); }
.status-badge--unhealthy { background: var(--color-status-error-bg); color: var(--color-status-error-text); border-color: var(--color-status-error-border); }
.status-badge--muted { background: var(--color-surface-tertiary); color: var(--color-text-muted); border-color: var(--color-border-primary); }
.probe-cell__type {
color: var(--color-text-muted);
font-size: 0.68rem;
font-weight: 500;
}
/* --- Detail panel --- */
.detail__name { margin: 0; font-size: 0.82rem; color: var(--color-text-primary); }
.detail {
align-content: start;
}
.detail__hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.detail__name {
margin: 0;
font-size: 0.9rem;
font-weight: 700;
color: var(--color-text-primary);
}
.detail__meta {
margin: 0.12rem 0 0;
color: var(--color-text-secondary);
font-size: 0.74rem;
}
.detail__grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.6rem;
gap: 0.25rem 0.6rem;
font-size: 0.74rem;
}
@@ -347,12 +504,22 @@ import { TopologyHost, TopologyTarget } from './topology.models';
padding-top: 0.1rem;
}
.detail__value { color: var(--color-text-secondary); }
.detail__value {
color: var(--color-text-secondary);
}
.detail p { margin: 0; color: var(--color-text-secondary); font-size: 0.75rem; }
.detail__summary {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.74rem;
line-height: 1.45;
}
/* --- Actions --- */
.actions { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.actions a {
display: inline-flex;
@@ -372,39 +539,28 @@ import { TopologyHost, TopologyTarget } from './topology.models';
color: var(--color-text-link-hover);
}
/* --- Empty states --- */
.empty-cell {
.empty-cell,
.empty-state {
text-align: center;
color: var(--color-text-muted);
font-size: 0.74rem;
padding: 1rem 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}
.empty-cell__icon { opacity: 0.4; flex-shrink: 0; }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 1rem;
color: var(--color-text-muted);
font-size: 0.76rem;
text-align: center;
@media (max-width: 1080px) {
.split {
grid-template-columns: 1fr;
}
}
.empty-state__icon { opacity: 0.35; }
.muted { color: var(--color-text-secondary); font-size: 0.74rem; }
@media (max-width: 960px) {
.filters { flex-direction: column; }
.filters__item--wide { min-width: auto; }
.split { grid-template-columns: 1fr; }
.filters {
flex-direction: column;
}
.filters__item--wide {
min-width: auto;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -412,45 +568,68 @@ import { TopologyHost, TopologyTarget } from './topology.models';
export class TopologyHostsPageComponent {
private readonly topologyApi = inject(TopologyDataService);
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly helperCtx = inject(StellaHelperContextService);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly searchQuery = signal('');
readonly runtimeFilter = signal('all');
readonly statusFilter = signal('all');
readonly statusFilter = signal<HostStatusFilter>('all');
readonly probeFilter = signal<ProbeFilter>('all');
readonly selectedHostId = signal('');
readonly hosts = signal<TopologyHost[]>([]);
readonly targets = signal<TopologyTarget[]>([]);
readonly runtimeOptions = computed(() =>
[...new Set(this.hosts().map((item) => item.runtimeType))].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })),
[...new Set(this.hosts().map((item) => item.runtimeType))]
.filter((value) => value.trim().length > 0)
.sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })),
);
readonly filteredHosts = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
const runtime = this.runtimeFilter();
const status = this.statusFilter();
const probe = this.probeFilter();
return this.hosts().filter((item) => {
const matchesQuery =
!query ||
[item.hostName, item.hostId, item.regionId, item.environmentId, item.runtimeType]
.some((value) => value.toLowerCase().includes(query));
[
item.hostName,
item.hostId,
item.regionId,
item.environmentId,
item.runtimeType,
item.agentId,
probeStatusLabel(normalizeProbeStatus(item.probeStatus)),
].some((value) => value.toLowerCase().includes(query));
const matchesRuntime = runtime === 'all' || item.runtimeType === runtime;
const normalizedStatus = item.status.trim().toLowerCase();
const matchesStatus = status === 'all' || normalizedStatus === status;
return matchesQuery && matchesRuntime && matchesStatus;
const monitored = hasRuntimeProbe(item);
const matchesProbe =
probe === 'all' ||
(probe === 'monitored' && monitored) ||
(probe === 'unmonitored' && !monitored);
return matchesQuery && matchesRuntime && matchesStatus && matchesProbe;
});
});
readonly selectedHost = computed(() => {
const selectedId = this.selectedHostId();
const filteredHosts = this.filteredHosts();
if (!selectedId) {
return this.filteredHosts()[0] ?? null;
return filteredHosts[0] ?? null;
}
return this.hosts().find((item) => item.hostId === selectedId) ?? null;
return filteredHosts.find((item) => item.hostId === selectedId) ?? filteredHosts[0] ?? null;
});
readonly selectedHostTargets = computed(() => {
@@ -458,7 +637,34 @@ export class TopologyHostsPageComponent {
if (!host) {
return [];
}
return this.targets().filter((item) => item.hostId === host.hostId);
return this.targets()
.filter((item) => item.hostId === host.hostId)
.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));
});
readonly selectedConnectionProfile = computed(() => {
const host = this.selectedHost();
if (!host) {
return inferConnectionProfile('-', 'docker_host');
}
return inferConnectionProfile(host.hostName, host.runtimeType);
});
readonly monitoredHostsCount = computed(() => this.hosts().filter((host) => hasRuntimeProbe(host)).length);
readonly helperContexts = computed(() => {
const contexts: string[] = [];
if (!this.loading() && this.filteredHosts().length === 0) {
contexts.push('empty-table');
}
if (
this.hosts().some((host) => host.status.trim().toLowerCase() === 'unknown') ||
(!this.loading() && this.hosts().length > 0 && this.monitoredHostsCount() === 0)
) {
contexts.push('health-unknown');
}
return contexts;
});
constructor() {
@@ -469,6 +675,7 @@ export class TopologyHostsPageComponent {
if (hostId) {
this.selectedHostId.set(hostId);
}
const environment = params.get('environment');
if (environment) {
this.searchQuery.set(environment);
@@ -479,6 +686,71 @@ export class TopologyHostsPageComponent {
this.context.contextVersion();
this.load();
});
effect(() => {
this.helperCtx.setScope('topology-hosts', this.helperContexts());
}, { allowSignalWrites: true });
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('topology-hosts'));
}
hostDetailLink(hostId: string): string[] {
return ['/environments/hosts', hostId];
}
hostStatusTone(status: string): 'success' | 'warning' | 'error' | 'neutral' {
const normalized = status.trim().toLowerCase();
if (normalized === 'healthy') {
return 'success';
}
if (normalized === 'degraded') {
return 'warning';
}
if (normalized === 'offline' || normalized === 'unhealthy') {
return 'error';
}
return 'neutral';
}
probeTone(host: TopologyHost): 'success' | 'error' | 'neutral' {
return probeStatusTone(normalizeProbeStatus(host.probeStatus));
}
probeLabel(host: TopologyHost): string {
return probeStatusLabel(normalizeProbeStatus(host.probeStatus));
}
probeType(host: TopologyHost): string {
const normalizedStatus = normalizeProbeStatus(host.probeStatus);
if (normalizedStatus !== 'active') {
return probeTypeLabel(host.probeType ?? inferConnectionProfile(host.hostName, host.runtimeType).probeRecommendation);
}
return probeTypeLabel(host.probeType ?? inferConnectionProfile(host.hostName, host.runtimeType).probeRecommendation);
}
showProbeType(host: TopologyHost): boolean {
return normalizeProbeStatus(host.probeStatus) === 'active';
}
probeTooltip(host: TopologyHost): string {
const lastSeen = host.probeLastHeartbeat ?? host.lastSeenAt;
const status = this.probeLabel(host);
const type = this.probeType(host);
if (!lastSeen) {
return `${status}${type ? ` - ${type}` : ''}`;
}
return `${status}${type ? ` - ${type}` : ''}. Last heartbeat ${this.relativeTime(lastSeen)}.`;
}
lastSeenLabel(host: TopologyHost): string {
const lastSeen = host.probeLastHeartbeat ?? host.lastSeenAt;
return lastSeen ? this.relativeTime(lastSeen) : '-';
}
private relativeTime(value: string): string {
return new RelativeTimePipe().transform(value) || value;
}
private load(): void {
@@ -494,9 +766,11 @@ export class TopologyHostsPageComponent {
next: ({ hosts, targets }) => {
this.hosts.set(hosts);
this.targets.set(targets);
if (!this.selectedHostId() && hosts.length > 0) {
this.selectedHostId.set(hosts[0].hostId);
}
this.loading.set(false);
},
error: (err: unknown) => {
@@ -508,5 +782,3 @@ export class TopologyHostsPageComponent {
});
}
}

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