feat(web): complete topology host verification ui

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

View File

@@ -0,0 +1,139 @@
# Sprint 003 - Host UI + Environment Verification
## Topic & Scope
- Surface runtime probe state on the topology hosts page so operators can see which hosts are actually monitored.
- Replace the stub host detail route with a usable host page that shows mapped targets, probe guidance, and recent activity.
- Move the environment verification work onto the current topology environment detail route instead of the older release-orchestrator casefile.
- Keep runtime verification truthful: ship probe-backed and drift-backed UI now, and degrade cleanly when container-level evidence is not available yet.
- Working directory: `src/Web/StellaOps.Web/src/app/`.
- Expected evidence: Angular build success, probe status visible on hosts page, host detail page functional, runtime verification visible on topology environment detail.
## Dependencies & Concurrency
- Depends on Sprint 002 for enriched probe/runtime evidence:
- Topology hosts API with probe status fields.
- Follow-on runtime/container evidence API for true running-vs-deployed digest comparison.
- Current canonical environment detail route is `src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts`.
- `src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts` is not the live user-facing route for this scope and should not receive new verification UX.
- Host detail and probe UX can ship before full backend completion as long as missing probe/container data is rendered as explicit degraded states rather than fabricated success.
- Reuse existing UI pieces where possible:
- `src/Web/StellaOps.Web/src/app/shared/ui/status-badge/status-badge.component.ts`
- `src/Web/StellaOps.Web/src/app/shared/ui/copy-to-clipboard/copy-to-clipboard.component.ts`
- `src/Web/StellaOps.Web/src/app/shared/pipes/format.pipes.ts`
## Documentation Prerequisites
- `.claude/plans/buzzing-napping-ember.md`
- `src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts`
- `src/Web/StellaOps.Web/src/app/features/topology/topology-host-detail-page.component.ts`
- `src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts`
- `src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts`
## Delivery Tracker
### TASK-001 - Add runtime probe state to topology hosts page
Status: DONE
Dependency: Sprint 002 topology host probe fields
Owners: Developer (FE)
Task description:
- Extend the topology host model with optional probe status, probe type, and probe heartbeat fields.
- Add a `Runtime Probe` column and a `Last Seen` column to the hosts table.
- Render probe states as explicit badges: `Active`, `Offline`, `Not installed`.
- Add a runtime-probe filter so the page can be scoped to monitored, unmonitored, or all hosts.
- Degrade to `Not monitored` when backend probe data is absent.
Completion criteria:
- [x] `TopologyHost` model extended with probe fields.
- [x] Hosts table shows runtime probe and last-seen columns.
- [x] Active probes show success badge with probe type label.
- [x] Offline probes show error badge.
- [x] Unmonitored hosts show neutral `Not installed`.
- [x] Probe filter scopes hosts correctly.
- [x] Angular build succeeds.
### TASK-002 - Replace the host detail stub with a route-backed host detail page
Status: DONE
Dependency: TASK-001
Owners: Developer (FE)
Task description:
- Rewrite `features/topology/topology-host-detail-page.component.ts` into a usable host detail page.
- Page sections:
1. Host overview header with host name, region, environment, runtime, health, and last seen.
2. Connection profile panel with derived SSH/WinRM/Docker family summary and truthful fallback when exact backend config is not exposed.
3. Mapped targets table with links to target detail.
4. Runtime probe panel with install guidance, copyable commands, and active/offline state.
5. Recent activity section derived from mapped target sync activity.
- The install panel may include a local command-preview toggle for enabling runtime verification, but must not pretend to persist host configuration without backend support.
Completion criteria:
- [x] Host detail page renders all five sections.
- [x] Connection panel shows a truthful connection profile summary.
- [x] Mapped targets link to target detail routes.
- [x] Probe installation guidance appears for unmonitored hosts.
- [x] Copy-to-clipboard works for install commands.
- [x] Active or offline probe state shows heartbeat context.
- [x] Page loads from direct URL and from host-list navigation.
### TASK-003 - Add runtime verification state to topology environment targets
Status: DONE
Dependency: Sprint 002 probe enrichment. Graceful fallback allowed before container evidence exists.
Owners: Developer (FE)
Task description:
- Modify `features/topology/topology-environment-detail-page.component.ts`.
- Add a `Runtime` column to the canonical Targets tab.
- Badge states: `Verified`, `Drift`, `Offline`, `Not monitored`.
- Current signal is derived from host probe heartbeat plus the dominant deployed release version in the environment.
- Tooltip text must explain the signal and degrade cleanly when probe/runtime evidence is missing.
Completion criteria:
- [x] Runtime column visible in topology environment Targets tab.
- [x] Verified targets show success badge.
- [x] Drift targets show warning badge with summary tooltip.
- [x] Offline probes show error badge.
- [x] Unmonitored targets show neutral badge.
- [x] Column degrades cleanly when probe data is unavailable.
- [x] Angular build succeeds.
### TASK-004 - Add runtime verification breakdown to topology environment Drift tab
Status: DONE
Dependency: TASK-003
Owners: Developer (FE)
Task description:
- Modify `features/topology/topology-environment-detail-page.component.ts`.
- Add a `Runtime Verification` section to the Drift tab below the existing drift summary.
- Show a per-target matrix with host, probe state, expected release version, observed release version, image digest, and runtime state.
- Highlight drift, offline, and unmonitored rows distinctly.
- Make the section collapsible with a summary header.
- Keep the UI truthful: do not claim container-level running-vs-deployed digest verification until a backend endpoint returns actual running inventory evidence.
Completion criteria:
- [x] Runtime Verification section visible in topology environment Drift tab.
- [x] Per-target matrix shows release/image context plus runtime state.
- [x] Verified targets show success status.
- [x] Drift rows show warning styling.
- [x] Offline or unmonitored rows show degraded styling.
- [x] Summary header shows counts.
- [x] Section collapses and expands.
- [x] Angular build succeeds.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-31 | Sprint created from host UI and environment verification plan. | Planning |
| 2026-03-31 | Re-scoped environment verification work onto topology routes because the older release-orchestrator environment casefile is not the canonical live path. | Implementer |
| 2026-03-31 | Implemented runtime probe coverage on topology hosts, replaced the host-detail stub, and added runtime verification to topology environment Targets and Drift tabs. | Implementer |
| 2026-03-31 | Verified `npx ng build --configuration development`, `npx tsc -p tsconfig.app.json --noEmit`, and focused `vitest.codex.config.ts` topology specs. | Implementer |
| 2026-03-31 | Synced topology component docs under `docs/modules/ui/component-preservation-map/components/weak-route/features/topology/`. | Implementer |
## Decisions & Risks
- Decision: Runtime verification work lands on topology routes because those are the live environment and host surfaces.
- Decision: Missing backend probe/container evidence must render as explicit degraded states such as `Not monitored`, never as fabricated success.
- Decision: Host install guidance uses platform-appropriate one-liners with copy support.
- Decision: Documentation sync for this sprint lives in `docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyHostDetailPageComponent.md` and `docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyEnvironmentDetailPageComponent.md`.
- Risk: Sprint 002 currently exposes probe fields in contracts, but the topology read model may still return null probe data. Mitigation: explicit fallback UI and no false verification claims.
- Risk: True container-level digest comparison still needs a backend endpoint with running inventory evidence. Mitigation: ship host/probe/drift-backed verification first and keep the deeper comparison as follow-on scope.
- Risk: `ng test --include ...` still pulls unrelated legacy suites and pre-existing failures from outside this sprint. Mitigation: use focused `vitest.codex.config.ts` topology specs plus `ng build` for this sprint's evidence until the broader test surface is repaired.
## Next Checkpoints
- Host list shows runtime probe badges and filtering.
- Host detail route shows mapped targets plus probe guidance.
- Topology environment Targets tab shows runtime verification states.
- Topology environment Drift tab shows verification summary and breakdown.

View File

@@ -10,13 +10,15 @@
- Selector: `app-topology-environment-detail-page`
## What Is It?
Topology Environment Detail Page appears to be a detail panel or supporting drill-down surface in the Topology area.
Topology Environment Detail Page is the canonical environment drill-down reused across topology, operations, and releases routes. It now shows runtime verification on the Targets tab and a runtime-verification breakdown on the Drift tab using probe heartbeat plus dominant deployed release state.
## Why It Likely Fell Out Of The Product
The component is still routed, but the current scan did not find an obvious menu or absolute page-action path to it.
The route remains a drill-down rather than a top-level navigation leaf, but it is an active supported surface. Sprint 003 extended it with runtime verification so operators can see verified, drift, offline, and not-monitored targets without leaving the environment detail flow.
## What Is Worth Preserving
Preserve the underlying workflow only if current product docs still claim the capability or if the component contains unique UI concepts.
- Preserve the topology-first environment posture workflow.
- Preserve the runtime verification column on Targets and the collapsible runtime breakdown on Drift.
- Preserve the explicit degraded states when probe or runtime evidence is missing.
## Likely Successor Or Merge Target
Needs branch-level review against current IA
@@ -40,13 +42,16 @@ Needs branch-level review against current IA
- none
### Runtime references outside routes/tests
- none
- `src/Web/StellaOps.Web/src/app/routes/operations.routes.ts`
- `src/Web/StellaOps.Web/src/app/routes/releases.routes.ts`
- `src/Web/StellaOps.Web/src/app/routes/topology.routes.ts`
## Related Docs
- [docs/modules/ui/README.md](../../../../../README.md)
- [docs/modules/ui/architecture.md](../../../../../architecture.md)
- [docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md](../../../../../../../docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md)
## Next-Pass Questions
- Confirm whether the route is reachable only through relative child-tab navigation.
- Check the corresponding product/docs promise before treating the page as dropped.
- Verify whether the route should be linked from the current shell or intentionally remain deep-linked only.
- Confirm whether the environment detail route should gain a more explicit shell entry from inventory views.
- Replace dominant-release heuristics with backend running-inventory evidence when Sprint 002 follow-on APIs land.
- Decide whether runtime verification should be promoted into overview KPIs after backend evidence becomes richer.

View File

@@ -10,13 +10,15 @@
- Selector: `app-topology-host-detail-page`
## What Is It?
Topology Host Detail Page appears to be a detail panel or supporting drill-down surface in the Topology area.
Topology Host Detail Page is the host drill-down for topology inventory. It now renders a route-backed host overview with connection profile, mapped targets, runtime probe status, install guidance for unmonitored hosts, and recent target-sync activity.
## Why It Likely Fell Out Of The Product
The component is still routed, but the current scan did not find an obvious menu or absolute page-action path to it.
The route is still a deep-link rather than a top-level navigation item, but it is no longer a dormant stub. Sprint 003 wired it from the topology hosts page and turned it into the primary host inspection surface for runtime coverage.
## What Is Worth Preserving
Preserve the underlying workflow only if current product docs still claim the capability or if the component contains unique UI concepts.
- Preserve the host-level runtime probe workflow and degraded-state handling.
- Preserve the mapped-target drill-down and recent activity summary.
- Preserve the copy-safe install guidance for hosts that do not yet report a probe heartbeat.
## Likely Successor Or Merge Target
Needs branch-level review against current IA
@@ -33,16 +35,17 @@ Needs branch-level review against current IA
- none
### Absolute page-action surfaces
- none
- Host links on `TopologyHostsPageComponent` route into this detail page.
### Runtime references outside routes/tests
- none
- `src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts`
## Related Docs
- [docs/modules/ui/README.md](../../../../../README.md)
- [docs/modules/ui/architecture.md](../../../../../architecture.md)
- [docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md](../../../../../../../docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md)
## Next-Pass Questions
- Confirm whether the route is reachable only through relative child-tab navigation.
- Check the corresponding product/docs promise before treating the page as dropped.
- Verify whether the route should be linked from the current shell or intentionally remain deep-linked only.
- Decide whether the host detail page should gain a direct shell affordance beyond the hosts-table deep link.
- Confirm when backend host connection metadata can replace the current truthful derived summary.
- Confirm when probe telemetry expands beyond heartbeat state into richer health counters.

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;
}