feat(web): complete topology host verification ui
This commit is contained in:
@@ -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.
|
||||||
@@ -10,13 +10,15 @@
|
|||||||
- Selector: `app-topology-environment-detail-page`
|
- Selector: `app-topology-environment-detail-page`
|
||||||
|
|
||||||
## What Is It?
|
## What Is It?
|
||||||
Topology Environment Detail Page appears to be a detail panel or supporting drill-down surface in the Topology area.
|
Topology Environment Detail Page is the canonical environment drill-down reused across topology, operations, and releases routes. It now shows runtime verification on the Targets tab and a runtime-verification breakdown on the Drift tab using probe heartbeat plus dominant deployed release state.
|
||||||
|
|
||||||
## Why It Likely Fell Out Of The Product
|
## Why It Likely Fell Out Of The Product
|
||||||
The component is still routed, but the current scan did not find an obvious menu or absolute page-action path to it.
|
The route remains a drill-down rather than a top-level navigation leaf, but it is an active supported surface. Sprint 003 extended it with runtime verification so operators can see verified, drift, offline, and not-monitored targets without leaving the environment detail flow.
|
||||||
|
|
||||||
## What Is Worth Preserving
|
## What Is Worth Preserving
|
||||||
Preserve the underlying workflow only if current product docs still claim the capability or if the component contains unique UI concepts.
|
- Preserve the topology-first environment posture workflow.
|
||||||
|
- Preserve the runtime verification column on Targets and the collapsible runtime breakdown on Drift.
|
||||||
|
- Preserve the explicit degraded states when probe or runtime evidence is missing.
|
||||||
|
|
||||||
## Likely Successor Or Merge Target
|
## Likely Successor Or Merge Target
|
||||||
Needs branch-level review against current IA
|
Needs branch-level review against current IA
|
||||||
@@ -40,13 +42,16 @@ Needs branch-level review against current IA
|
|||||||
- none
|
- none
|
||||||
|
|
||||||
### Runtime references outside routes/tests
|
### Runtime references outside routes/tests
|
||||||
- none
|
- `src/Web/StellaOps.Web/src/app/routes/operations.routes.ts`
|
||||||
|
- `src/Web/StellaOps.Web/src/app/routes/releases.routes.ts`
|
||||||
|
- `src/Web/StellaOps.Web/src/app/routes/topology.routes.ts`
|
||||||
|
|
||||||
## Related Docs
|
## Related Docs
|
||||||
- [docs/modules/ui/README.md](../../../../../README.md)
|
- [docs/modules/ui/README.md](../../../../../README.md)
|
||||||
- [docs/modules/ui/architecture.md](../../../../../architecture.md)
|
- [docs/modules/ui/architecture.md](../../../../../architecture.md)
|
||||||
|
- [docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md](../../../../../../../docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md)
|
||||||
|
|
||||||
## Next-Pass Questions
|
## Next-Pass Questions
|
||||||
- Confirm whether the route is reachable only through relative child-tab navigation.
|
- Confirm whether the environment detail route should gain a more explicit shell entry from inventory views.
|
||||||
- Check the corresponding product/docs promise before treating the page as dropped.
|
- Replace dominant-release heuristics with backend running-inventory evidence when Sprint 002 follow-on APIs land.
|
||||||
- Verify whether the route should be linked from the current shell or intentionally remain deep-linked only.
|
- Decide whether runtime verification should be promoted into overview KPIs after backend evidence becomes richer.
|
||||||
|
|||||||
@@ -10,13 +10,15 @@
|
|||||||
- Selector: `app-topology-host-detail-page`
|
- Selector: `app-topology-host-detail-page`
|
||||||
|
|
||||||
## What Is It?
|
## What Is It?
|
||||||
Topology Host Detail Page appears to be a detail panel or supporting drill-down surface in the Topology area.
|
Topology Host Detail Page is the host drill-down for topology inventory. It now renders a route-backed host overview with connection profile, mapped targets, runtime probe status, install guidance for unmonitored hosts, and recent target-sync activity.
|
||||||
|
|
||||||
## Why It Likely Fell Out Of The Product
|
## Why It Likely Fell Out Of The Product
|
||||||
The component is still routed, but the current scan did not find an obvious menu or absolute page-action path to it.
|
The route is still a deep-link rather than a top-level navigation item, but it is no longer a dormant stub. Sprint 003 wired it from the topology hosts page and turned it into the primary host inspection surface for runtime coverage.
|
||||||
|
|
||||||
## What Is Worth Preserving
|
## What Is Worth Preserving
|
||||||
Preserve the underlying workflow only if current product docs still claim the capability or if the component contains unique UI concepts.
|
- Preserve the host-level runtime probe workflow and degraded-state handling.
|
||||||
|
- Preserve the mapped-target drill-down and recent activity summary.
|
||||||
|
- Preserve the copy-safe install guidance for hosts that do not yet report a probe heartbeat.
|
||||||
|
|
||||||
## Likely Successor Or Merge Target
|
## Likely Successor Or Merge Target
|
||||||
Needs branch-level review against current IA
|
Needs branch-level review against current IA
|
||||||
@@ -33,16 +35,17 @@ Needs branch-level review against current IA
|
|||||||
- none
|
- none
|
||||||
|
|
||||||
### Absolute page-action surfaces
|
### Absolute page-action surfaces
|
||||||
- none
|
- Host links on `TopologyHostsPageComponent` route into this detail page.
|
||||||
|
|
||||||
### Runtime references outside routes/tests
|
### Runtime references outside routes/tests
|
||||||
- none
|
- `src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts`
|
||||||
|
|
||||||
## Related Docs
|
## Related Docs
|
||||||
- [docs/modules/ui/README.md](../../../../../README.md)
|
- [docs/modules/ui/README.md](../../../../../README.md)
|
||||||
- [docs/modules/ui/architecture.md](../../../../../architecture.md)
|
- [docs/modules/ui/architecture.md](../../../../../architecture.md)
|
||||||
|
- [docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md](../../../../../../../docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md)
|
||||||
|
|
||||||
## Next-Pass Questions
|
## Next-Pass Questions
|
||||||
- Confirm whether the route is reachable only through relative child-tab navigation.
|
- Decide whether the host detail page should gain a direct shell affordance beyond the hosts-table deep link.
|
||||||
- Check the corresponding product/docs promise before treating the page as dropped.
|
- Confirm when backend host connection metadata can replace the current truthful derived summary.
|
||||||
- Verify whether the route should be linked from the current shell or intentionally remain deep-linked only.
|
- Confirm when probe telemetry expands beyond heartbeat state into richer health counters.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { catchError, forkJoin, of, take } from 'rxjs';
|
import { catchError, forkJoin, of, take } from 'rxjs';
|
||||||
|
|
||||||
@@ -25,11 +25,20 @@ import {
|
|||||||
TopologyPromotionPath,
|
TopologyPromotionPath,
|
||||||
TopologyTarget,
|
TopologyTarget,
|
||||||
} from './topology.models';
|
} from './topology.models';
|
||||||
|
import {
|
||||||
|
buildRuntimeVerificationSummary,
|
||||||
|
dominantReleaseVersion,
|
||||||
|
runtimeVerificationTone,
|
||||||
|
shortDigest,
|
||||||
|
shortId,
|
||||||
|
type RuntimeVerificationStatus,
|
||||||
|
} from './topology-runtime.helpers';
|
||||||
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
|
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
|
||||||
import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component';
|
import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component';
|
||||||
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
|
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
|
||||||
import { StatGroupComponent } from '../../shared/components/stat-card/stat-card.component';
|
import { StatGroupComponent } from '../../shared/components/stat-card/stat-card.component';
|
||||||
import { RelativeTimePipe, DurationPipe } from '../../shared/pipes/format.pipes';
|
import { RelativeTimePipe, DurationPipe } from '../../shared/pipes/format.pipes';
|
||||||
|
import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service';
|
||||||
|
|
||||||
type EnvironmentTab = 'overview' | 'targets' | 'readiness' | 'deployments' | 'agents' | 'security' | 'evidence' | 'drift' | 'data-quality';
|
type EnvironmentTab = 'overview' | 'targets' | 'readiness' | 'deployments' | 'agents' | 'security' | 'evidence' | 'drift' | 'data-quality';
|
||||||
|
|
||||||
@@ -70,9 +79,9 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
template: `
|
template: `
|
||||||
<section class="env-detail">
|
<section class="env-detail">
|
||||||
|
|
||||||
<!-- ── Header ── -->
|
<!-- Header -->
|
||||||
<header class="hdr">
|
<header class="hdr">
|
||||||
<a class="hdr__back" routerLink="/environments/overview">← Environments</a>
|
<a class="hdr__back" routerLink="/environments/overview"><- Environments</a>
|
||||||
<div class="hdr__main">
|
<div class="hdr__main">
|
||||||
<div class="hdr__title-row">
|
<div class="hdr__title-row">
|
||||||
<h1>{{ environmentLabel() }}</h1>
|
<h1>{{ environmentLabel() }}</h1>
|
||||||
@@ -80,12 +89,12 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
@if (currentRelease()) { <span class="chip chip--release">{{ currentRelease() }}</span> }
|
@if (currentRelease()) { <span class="chip chip--release">{{ currentRelease() }}</span> }
|
||||||
@if (isFrozen()) { <app-status-badge status="error" label="FROZEN" [showIcon]="true" size="sm" /> }
|
@if (isFrozen()) { <app-status-badge status="error" label="FROZEN" [showIcon]="true" size="sm" /> }
|
||||||
</div>
|
</div>
|
||||||
<p class="hdr__sub">{{ regionLabel() }} · {{ environmentTypeLabel() }}</p>
|
<p class="hdr__sub">{{ regionLabel() }} | {{ environmentTypeLabel() }}</p>
|
||||||
@if (promotionLine()) { <p class="hdr__promo">{{ promotionLine() }}</p> }
|
@if (promotionLine()) { <p class="hdr__promo">{{ promotionLine() }}</p> }
|
||||||
</div>
|
</div>
|
||||||
<div class="hdr__actions">
|
<div class="hdr__actions">
|
||||||
<button class="btn btn--secondary btn--sm" (click)="refresh()">⟳ Refresh</button>
|
<button class="btn btn--secondary btn--sm" (click)="refresh()">Refresh</button>
|
||||||
<a class="btn btn--primary btn--sm" [routerLink]="['/releases/deployments/new']" [queryParams]="{ environment: environmentId() }">Deploy</a>
|
<a class="btn btn--primary btn--sm" [routerLink]="['/releases/promotions/create']" [queryParams]="{ targetEnvironmentId: environmentId() }">Request Promotion</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -104,7 +113,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
} @else {
|
} @else {
|
||||||
@switch (activeTab()) {
|
@switch (activeTab()) {
|
||||||
|
|
||||||
<!-- ════════ OVERVIEW ════════ -->
|
<!-- Overview -->
|
||||||
@case ('overview') {
|
@case ('overview') {
|
||||||
<div class="overview-layout">
|
<div class="overview-layout">
|
||||||
<div class="overview-main">
|
<div class="overview-main">
|
||||||
@@ -135,7 +144,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel__hdr">
|
<div class="panel__hdr">
|
||||||
<h2>Readiness Snapshot</h2>
|
<h2>Readiness Snapshot</h2>
|
||||||
<a class="panel__link" (click)="activeTab.set('readiness')">View full →</a>
|
<a class="panel__link" (click)="activeTab.set('readiness')">View full -></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="readiness-mini">
|
<div class="readiness-mini">
|
||||||
<span class="rm rm--ok">{{ readyTargetsCnt() }} pass</span>
|
<span class="rm rm--ok">{{ readyTargetsCnt() }} pass</span>
|
||||||
@@ -170,30 +179,67 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ════════ TARGETS ════════ -->
|
<!-- Targets -->
|
||||||
@case ('targets') {
|
@case ('targets') {
|
||||||
<article class="panel">
|
<app-stat-group columns="4">
|
||||||
<table class="stella-table stella-table--striped stella-table--hoverable">
|
<app-metric-card label="Verified" [value]="runtimeVerifiedCount()" [severity]="runtimeVerifiedCount() === targetRows().length && targetRows().length > 0 ? 'healthy' : 'warning'" />
|
||||||
<thead><tr><th>Target</th><th>Type</th><th>Host</th><th>Agent</th><th>Status</th><th>Last Sync</th></tr></thead>
|
<app-metric-card label="Drift" [value]="runtimeDriftCount()" [severity]="runtimeDriftCount() > 0 ? 'warning' : 'healthy'" />
|
||||||
<tbody>
|
<app-metric-card label="Probe Offline" [value]="runtimeOfflineCount()" [severity]="runtimeOfflineCount() > 0 ? 'critical' : 'healthy'" />
|
||||||
@for (t of targetRows(); track t.targetId) {
|
<app-metric-card label="Not Monitored" [value]="runtimeUnmonitoredCount()" [severity]="runtimeUnmonitoredCount() > 0 ? 'warning' : 'healthy'" />
|
||||||
<tr>
|
</app-stat-group>
|
||||||
<td>{{ t.name }}</td>
|
|
||||||
<td>{{ t.targetType }}</td>
|
@if (targetRows().length === 0) {
|
||||||
<td>{{ hostName(t.hostId) }}</td>
|
<article class="panel panel--empty">
|
||||||
<td>{{ agentName(t.agentId) }}</td>
|
<div class="empty-panel">
|
||||||
<td><app-status-badge [status]="healthToStatus(t.healthStatus)" [label]="t.healthStatus" [showIcon]="true" size="sm" /></td>
|
<div class="empty-panel__icon" aria-hidden="true">O</div>
|
||||||
<td>{{ t.lastSyncAt | relativeTime }}</td>
|
<div class="empty-panel__body">
|
||||||
</tr>
|
<h3>No targets are mapped to this environment yet</h3>
|
||||||
} @empty {
|
<p>
|
||||||
<tr><td colspan="6" class="muted">No targets in this environment.</td></tr>
|
Targets appear after Stella has discovered runtime hosts and associated workloads with this environment.
|
||||||
}
|
Until that happens, readiness checks, drift detection, and deployment health all stay empty here.
|
||||||
</tbody>
|
</p>
|
||||||
</table>
|
</div>
|
||||||
</article>
|
<div class="empty-panel__actions">
|
||||||
|
<a class="btn btn--primary btn--sm" [routerLink]="['/environments/targets']" [queryParams]="{ environment: environmentId() }">Review target inventory</a>
|
||||||
|
<a class="btn btn--secondary btn--sm" [routerLink]="['/ops/integrations']">Open integrations</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
} @else {
|
||||||
|
<article class="panel">
|
||||||
|
<p class="runtime-note">
|
||||||
|
Runtime state combines host probe heartbeat plus the dominant deployed release version for this environment. Missing probe data renders as Not monitored.
|
||||||
|
</p>
|
||||||
|
<table class="stella-table stella-table--striped stella-table--hoverable">
|
||||||
|
<thead><tr><th>Target</th><th>Type</th><th>Host</th><th>Agent</th><th>Status</th><th>Runtime</th><th>Last Sync</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of runtimeVerificationRows(); track row.target.targetId) {
|
||||||
|
<tr
|
||||||
|
[class.runtime-row--drift]="row.summary.status === 'drift'"
|
||||||
|
[class.runtime-row--offline]="row.summary.status === 'offline'"
|
||||||
|
[class.runtime-row--unmonitored]="row.summary.status === 'not_monitored'"
|
||||||
|
>
|
||||||
|
<td>{{ row.target.name }}</td>
|
||||||
|
<td>{{ row.target.targetType }}</td>
|
||||||
|
<td>{{ hostName(row.target.hostId) }}</td>
|
||||||
|
<td>{{ agentName(row.target.agentId) }}</td>
|
||||||
|
<td><app-status-badge [status]="healthToStatus(row.target.healthStatus)" [label]="row.target.healthStatus" [showIcon]="true" size="sm" /></td>
|
||||||
|
<td>
|
||||||
|
<div class="runtime-cell" [title]="row.summary.detail">
|
||||||
|
<app-status-badge [status]="runtimeStatusTone(row.summary.status)" [label]="row.summary.label" [showIcon]="true" size="sm" />
|
||||||
|
<span class="runtime-caption">{{ row.summary.probeTypeLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.target.lastSyncAt | relativeTime }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ════════ READINESS ════════ -->
|
<!-- Readiness -->
|
||||||
@case ('readiness') {
|
@case ('readiness') {
|
||||||
<app-stat-group columns="3">
|
<app-stat-group columns="3">
|
||||||
<app-metric-card label="Ready" [value]="readyTargetsCnt()" [severity]="readyTargetsCnt() === readinessReports().length ? 'healthy' : 'warning'" />
|
<app-metric-card label="Ready" [value]="readyTargetsCnt()" [severity]="readyTargetsCnt() === readinessReports().length ? 'healthy' : 'warning'" />
|
||||||
@@ -201,49 +247,77 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
<app-metric-card label="Pending" [value]="pendingTargetsCnt()" [severity]="pendingTargetsCnt() > 0 ? 'warning' : 'healthy'" />
|
<app-metric-card label="Pending" [value]="pendingTargetsCnt()" [severity]="pendingTargetsCnt() > 0 ? 'warning' : 'healthy'" />
|
||||||
</app-stat-group>
|
</app-stat-group>
|
||||||
|
|
||||||
<article class="panel">
|
@if (readinessReports().length === 0) {
|
||||||
<div class="panel__hdr">
|
<article class="panel panel--empty">
|
||||||
<h2>Gate Status</h2>
|
<div class="empty-panel">
|
||||||
<button class="btn btn--primary btn--sm" (click)="validateAll()" [disabled]="validatingAll()">
|
<div class="empty-panel__icon" aria-hidden="true">OK</div>
|
||||||
{{ validatingAll() ? 'Validating...' : 'Validate All' }}
|
<div class="empty-panel__body">
|
||||||
</button>
|
<h3>No readiness checks have been captured yet</h3>
|
||||||
</div>
|
<p>
|
||||||
<div class="gg-wrap">
|
Readiness records show whether agents are bound, registries are reachable, and connectivity gates are passing for each target.
|
||||||
<table class="stella-table stella-table--striped stella-table--hoverable">
|
@if (targetRows().length === 0) {
|
||||||
<thead>
|
Add targets to this environment first, then Stella can validate promotion readiness.
|
||||||
<tr>
|
} @else {
|
||||||
<th>Target</th>
|
Run validation once to populate the gate matrix before reviewing or promoting this environment.
|
||||||
@for (gn of gateNames; track gn) { <th class="th-gate">{{ fmtGate(gn) }}</th> }
|
}
|
||||||
<th>Ready</th>
|
</p>
|
||||||
<th>Actions</th>
|
</div>
|
||||||
</tr>
|
<div class="empty-panel__actions">
|
||||||
</thead>
|
@if (targetRows().length === 0) {
|
||||||
<tbody>
|
<a class="btn btn--primary btn--sm" [routerLink]="['/environments/targets']" [queryParams]="{ environment: environmentId() }">Review targets</a>
|
||||||
@for (rpt of readinessReports(); track rpt.targetId) {
|
<a class="btn btn--secondary btn--sm" [routerLink]="['/ops/integrations']">Open integrations</a>
|
||||||
<tr>
|
} @else {
|
||||||
<td>{{ targetName(rpt.targetId) }}</td>
|
<button class="btn btn--primary btn--sm" (click)="validateAll()" [disabled]="validatingAll()">
|
||||||
@for (gn of gateNames; track gn) {
|
{{ validatingAll() ? 'Validating...' : 'Validate All' }}
|
||||||
<td class="td-gate">
|
</button>
|
||||||
<app-status-badge [status]="gateStatus(rpt, gn)" [label]="gateLabel(rpt, gn)" size="sm" />
|
<button class="btn btn--secondary btn--sm" (click)="activeTab.set('targets')">Inspect targets</button>
|
||||||
</td>
|
|
||||||
}
|
|
||||||
<td><app-status-badge [status]="rpt.isReady ? 'success' : 'error'" [label]="rpt.isReady ? 'Yes' : 'No'" [showIcon]="true" size="sm" /></td>
|
|
||||||
<td>
|
|
||||||
<button class="btn btn--secondary btn--xs" (click)="validateTarget(rpt.targetId)" [disabled]="validating().has(rpt.targetId)">
|
|
||||||
{{ validating().has(rpt.targetId) ? '...' : 'Validate' }}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr><td [attr.colspan]="gateNames.length + 3" class="muted">No readiness data. Run validation to check targets.</td></tr>
|
|
||||||
}
|
}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</article>
|
} @else {
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel__hdr">
|
||||||
|
<h2>Gate Status</h2>
|
||||||
|
<button class="btn btn--primary btn--sm" (click)="validateAll()" [disabled]="validatingAll()">
|
||||||
|
{{ validatingAll() ? 'Validating...' : 'Validate All' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="gg-wrap">
|
||||||
|
<table class="stella-table stella-table--striped stella-table--hoverable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Target</th>
|
||||||
|
@for (gn of gateNames; track gn) { <th class="th-gate">{{ fmtGate(gn) }}</th> }
|
||||||
|
<th>Ready</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (rpt of readinessReports(); track rpt.targetId) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ targetName(rpt.targetId) }}</td>
|
||||||
|
@for (gn of gateNames; track gn) {
|
||||||
|
<td class="td-gate">
|
||||||
|
<app-status-badge [status]="gateStatus(rpt, gn)" [label]="gateLabel(rpt, gn)" size="sm" />
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td><app-status-badge [status]="rpt.isReady ? 'success' : 'error'" [label]="rpt.isReady ? 'Yes' : 'No'" [showIcon]="true" size="sm" /></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn--secondary btn--xs" (click)="validateTarget(rpt.targetId)" [disabled]="validating().has(rpt.targetId)">
|
||||||
|
{{ validating().has(rpt.targetId) ? '...' : 'Validate' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ════════ RUNS ════════ -->
|
<!-- Runs -->
|
||||||
@case ('deployments') {
|
@case ('deployments') {
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<table class="stella-table stella-table--striped stella-table--hoverable">
|
<table class="stella-table stella-table--striped stella-table--hoverable">
|
||||||
@@ -253,9 +327,9 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ run.releaseName }}</td>
|
<td>{{ run.releaseName }}</td>
|
||||||
<td><app-status-badge [status]="runStatusType(run.status)" [label]="run.status" size="sm" /></td>
|
<td><app-status-badge [status]="runStatusType(run.status)" [label]="run.status" size="sm" /></td>
|
||||||
<td>{{ run.durationMs ? (run.durationMs | duration) : '—' }}</td>
|
<td>{{ run.durationMs ? (run.durationMs | duration) : '-' }}</td>
|
||||||
<td>{{ run.occurredAt | relativeTime }}</td>
|
<td>{{ run.occurredAt | relativeTime }}</td>
|
||||||
<td><a class="link" [routerLink]="['/releases/runs', run.activityId, 'summary']">View →</a></td>
|
<td><a class="link" [routerLink]="['/releases/runs', run.activityId, 'summary']">View -></a></td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr><td colspan="5" class="muted">No deployment runs in this scope.</td></tr>
|
<tr><td colspan="5" class="muted">No deployment runs in this scope.</td></tr>
|
||||||
@@ -265,29 +339,46 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ════════ AGENTS ════════ -->
|
<!-- Agents -->
|
||||||
@case ('agents') {
|
@case ('agents') {
|
||||||
<article class="panel">
|
@if (agentRows().length === 0) {
|
||||||
<table class="stella-table stella-table--striped stella-table--hoverable">
|
<article class="panel panel--empty">
|
||||||
<thead><tr><th>Agent</th><th>Status</th><th>Capabilities</th><th>Targets</th><th>Heartbeat</th></tr></thead>
|
<div class="empty-panel">
|
||||||
<tbody>
|
<div class="empty-panel__icon" aria-hidden="true">CFG</div>
|
||||||
@for (a of agentRows(); track a.agentId) {
|
<div class="empty-panel__body">
|
||||||
<tr>
|
<h3>No agents are reporting for this environment</h3>
|
||||||
<td>{{ a.agentName }}</td>
|
<p>
|
||||||
<td><app-status-badge [status]="a.status === 'active' ? 'success' : 'warning'" [label]="a.status" [showIcon]="true" size="sm" /></td>
|
Agents are what let Stella validate readiness and execute deployments on real hosts.
|
||||||
<td>{{ a.capabilities.join(', ') || '—' }}</td>
|
Until an agent is connected here, this environment can describe topology but cannot perform release work.
|
||||||
<td>{{ a.assignedTargetCount }}</td>
|
</p>
|
||||||
<td>{{ a.lastHeartbeatAt | relativeTime }}</td>
|
</div>
|
||||||
</tr>
|
<div class="empty-panel__actions">
|
||||||
} @empty {
|
<a class="btn btn--primary btn--sm" [routerLink]="['/ops/operations/agents']">Open agent fleet</a>
|
||||||
<tr><td colspan="5" class="muted">No agents in this environment.</td></tr>
|
<a class="btn btn--secondary btn--sm" [routerLink]="['/ops/operations/doctor']">Run diagnostics</a>
|
||||||
}
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</article>
|
||||||
</article>
|
} @else {
|
||||||
|
<article class="panel">
|
||||||
|
<table class="stella-table stella-table--striped stella-table--hoverable">
|
||||||
|
<thead><tr><th>Agent</th><th>Status</th><th>Capabilities</th><th>Targets</th><th>Heartbeat</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of agentRows(); track a.agentId) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ a.agentName }}</td>
|
||||||
|
<td><app-status-badge [status]="a.status === 'active' ? 'success' : 'warning'" [label]="a.status" [showIcon]="true" size="sm" /></td>
|
||||||
|
<td>{{ a.capabilities.join(', ') || '-' }}</td>
|
||||||
|
<td>{{ a.assignedTargetCount }}</td>
|
||||||
|
<td>{{ a.lastHeartbeatAt | relativeTime }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ════════ SECURITY ════════ -->
|
<!-- Security -->
|
||||||
@case ('security') {
|
@case ('security') {
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<table class="stella-table stella-table--striped stella-table--hoverable">
|
<table class="stella-table stella-table--striped stella-table--hoverable">
|
||||||
@@ -297,14 +388,14 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ f.cveId }}</td>
|
<td class="mono">{{ f.cveId }}</td>
|
||||||
<td><app-status-badge [status]="severityStatus(f.severity)" [label]="f.severity" [showIcon]="true" size="sm" /></td>
|
<td><app-status-badge [status]="severityStatus(f.severity)" [label]="f.severity" [showIcon]="true" size="sm" /></td>
|
||||||
<td>{{ f.cvss ?? '—' }}</td>
|
<td>{{ f.cvss ?? '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
@if (f.reachable != null) {
|
@if (f.reachable != null) {
|
||||||
<app-status-badge [status]="f.reachable ? 'error' : 'neutral'" [label]="f.reachable ? 'Yes' : 'No'" size="sm" />
|
<app-status-badge [status]="f.reachable ? 'error' : 'neutral'" [label]="f.reachable ? 'Yes' : 'No'" size="sm" />
|
||||||
} @else { — }
|
} @else { - }
|
||||||
</td>
|
</td>
|
||||||
<td>{{ f.effectiveDisposition }}</td>
|
<td>{{ f.effectiveDisposition }}</td>
|
||||||
<td><a class="link" [routerLink]="['/triage/artifacts']" [queryParams]="{ cve: f.cveId }">View →</a></td>
|
<td><a class="link" [routerLink]="['/triage/artifacts']" [queryParams]="{ cve: f.cveId }">View -></a></td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr><td colspan="6" class="muted">No active findings in this scope.</td></tr>
|
<tr><td colspan="6" class="muted">No active findings in this scope.</td></tr>
|
||||||
@@ -314,7 +405,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ════════ EVIDENCE ════════ -->
|
<!-- Evidence -->
|
||||||
@case ('evidence') {
|
@case ('evidence') {
|
||||||
@if (capsuleRows().length === 0) {
|
@if (capsuleRows().length === 0) {
|
||||||
<div class="panel"><p class="muted">No decision capsules in this scope.</p></div>
|
<div class="panel"><p class="muted">No decision capsules in this scope.</p></div>
|
||||||
@@ -348,7 +439,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ════════ DRIFT ════════ -->
|
<!-- Drift -->
|
||||||
@case ('drift') {
|
@case ('drift') {
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
@if (driftDetected()) {
|
@if (driftDetected()) {
|
||||||
@@ -365,9 +456,74 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel__hdr">
|
||||||
|
<div>
|
||||||
|
<h2>Runtime Verification</h2>
|
||||||
|
<p class="runtime-note">
|
||||||
|
Probe-backed runtime status is shown here. True running-vs-deployed container digest comparison is not available until the backend exposes running inventory evidence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--secondary btn--xs" (click)="runtimeSectionExpanded.set(!runtimeSectionExpanded())">
|
||||||
|
{{ runtimeSectionExpanded() ? 'Collapse' : 'Expand' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="runtime-summary">
|
||||||
|
<span class="runtime-summary__item runtime-summary__item--verified">{{ runtimeVerifiedCount() }} verified</span>
|
||||||
|
<span class="runtime-summary__item runtime-summary__item--drift">{{ runtimeDriftCount() }} drift</span>
|
||||||
|
<span class="runtime-summary__item runtime-summary__item--offline">{{ runtimeOfflineCount() }} offline</span>
|
||||||
|
<span class="runtime-summary__item runtime-summary__item--unmonitored">{{ runtimeUnmonitoredCount() }} not monitored</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (runtimeSectionExpanded()) {
|
||||||
|
<div class="runtime-matrix-wrap">
|
||||||
|
<table class="stella-table stella-table--striped stella-table--hoverable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Probe</th>
|
||||||
|
<th>Expected Release</th>
|
||||||
|
<th>Observed Release</th>
|
||||||
|
<th>Image Digest</th>
|
||||||
|
<th>Runtime</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of runtimeVerificationRows(); track row.target.targetId) {
|
||||||
|
<tr
|
||||||
|
[class.runtime-row--drift]="row.summary.status === 'drift'"
|
||||||
|
[class.runtime-row--offline]="row.summary.status === 'offline'"
|
||||||
|
[class.runtime-row--unmonitored]="row.summary.status === 'not_monitored'"
|
||||||
|
>
|
||||||
|
<td>{{ row.target.name }}</td>
|
||||||
|
<td>{{ hostName(row.target.hostId) }}</td>
|
||||||
|
<td>{{ row.summary.probeTypeLabel }}</td>
|
||||||
|
<td class="mono" [title]="row.summary.expectedReleaseVersion">{{ shortId(row.summary.expectedReleaseVersion) }}</td>
|
||||||
|
<td class="mono" [title]="row.summary.observedReleaseVersion">{{ shortId(row.summary.observedReleaseVersion) }}</td>
|
||||||
|
<td class="mono" [title]="row.target.imageDigest">{{ shortDigest(row.target.imageDigest) }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="runtime-cell" [title]="row.summary.detail">
|
||||||
|
<app-status-badge [status]="runtimeStatusTone(row.summary.status)" [label]="row.summary.label" [showIcon]="true" size="sm" />
|
||||||
|
@if (row.summary.lastVerifiedAt) {
|
||||||
|
<span class="runtime-caption">{{ row.summary.lastVerifiedAt | relativeTime }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="7" class="muted">No target runtime verification data in this scope.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ════════ DATA QUALITY ════════ -->
|
<!-- Data quality -->
|
||||||
@case ('data-quality') {
|
@case ('data-quality') {
|
||||||
<app-stat-group columns="4">
|
<app-stat-group columns="4">
|
||||||
<app-metric-card label="Target Coverage" [value]="targetRows().length" [severity]="targetRows().length === 0 ? 'critical' : 'healthy'" subtitle="Topology targets in scope" />
|
<app-metric-card label="Target Coverage" [value]="targetRows().length" [severity]="targetRows().length === 0 ? 'critical' : 'healthy'" subtitle="Topology targets in scope" />
|
||||||
@@ -383,7 +539,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
styles: [`
|
styles: [`
|
||||||
.env-detail { display: grid; gap: 0.75rem; }
|
.env-detail { display: grid; gap: 0.75rem; }
|
||||||
|
|
||||||
/* ── Header ── */
|
/* Header */
|
||||||
.hdr { display: grid; gap: 0.25rem; padding: 0.6rem 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); }
|
.hdr { display: grid; gap: 0.25rem; padding: 0.6rem 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); }
|
||||||
.hdr__back { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); text-decoration: none; }
|
.hdr__back { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); text-decoration: none; }
|
||||||
.hdr__back:hover { text-decoration: underline; }
|
.hdr__back:hover { text-decoration: underline; }
|
||||||
@@ -395,7 +551,7 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
.hdr__actions { display: flex; gap: 0.35rem; align-items: center; justify-content: flex-end; }
|
.hdr__actions { display: flex; gap: 0.35rem; align-items: center; justify-content: flex-end; }
|
||||||
.chip--release { font-size: 0.66rem; padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); background: var(--color-surface-secondary); color: var(--color-text-secondary); font-family: var(--font-mono, monospace); }
|
.chip--release { font-size: 0.66rem; padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); background: var(--color-surface-secondary); color: var(--color-text-secondary); font-family: var(--font-mono, monospace); }
|
||||||
|
|
||||||
/* ── Buttons ── */
|
/* Buttons */
|
||||||
.btn { padding: 0.3rem 0.65rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.74rem; font-weight: 500; border: none; text-decoration: none; display: inline-flex; align-items: center; gap: 0.2rem; transition: background 150ms ease; }
|
.btn { padding: 0.3rem 0.65rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.74rem; font-weight: 500; border: none; text-decoration: none; display: inline-flex; align-items: center; gap: 0.2rem; transition: background 150ms ease; }
|
||||||
.btn--sm { padding: 0.28rem 0.6rem; }
|
.btn--sm { padding: 0.28rem 0.6rem; }
|
||||||
.btn--xs { padding: 0.2rem 0.4rem; font-size: 0.68rem; }
|
.btn--xs { padding: 0.2rem 0.4rem; font-size: 0.68rem; }
|
||||||
@@ -409,33 +565,54 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
.link:hover { text-decoration: underline; }
|
.link:hover { text-decoration: underline; }
|
||||||
.mono { font-family: var(--font-mono, monospace); font-size: 0.72rem; }
|
.mono { font-family: var(--font-mono, monospace); font-size: 0.72rem; }
|
||||||
|
|
||||||
/* ── Panels ── */
|
/* Panels */
|
||||||
.panel { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.7rem; display: grid; gap: 0.4rem; }
|
.panel { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.7rem; display: grid; gap: 0.4rem; }
|
||||||
|
.panel--empty { padding: 0.85rem; }
|
||||||
.panel h2 { margin: 0; font-size: 0.88rem; font-weight: 600; }
|
.panel h2 { margin: 0; font-size: 0.88rem; font-weight: 600; }
|
||||||
.panel__hdr { display: flex; justify-content: space-between; align-items: center; }
|
.panel__hdr { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.panel__link { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); cursor: pointer; text-decoration: none; }
|
.panel__link { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); cursor: pointer; text-decoration: none; }
|
||||||
.panel__link:hover { text-decoration: underline; }
|
.panel__link:hover { text-decoration: underline; }
|
||||||
.panel__ok { margin: 0; color: var(--color-status-success, #22c55e); font-size: 0.76rem; }
|
.panel__ok { margin: 0; color: var(--color-status-success, #22c55e); font-size: 0.76rem; }
|
||||||
|
.empty-panel { display: grid; gap: 0.75rem; align-items: start; }
|
||||||
|
.empty-panel__icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: color-mix(in srgb, var(--color-brand-soft, rgba(59,130,246,0.12)) 75%, transparent);
|
||||||
|
color: var(--color-text-link, var(--color-brand, #3b82f6));
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.empty-panel__body { display: grid; gap: 0.35rem; }
|
||||||
|
.empty-panel__body h3 { margin: 0; font-size: 0.9rem; font-weight: 600; }
|
||||||
|
.empty-panel__body p { margin: 0; color: var(--color-text-secondary); font-size: 0.76rem; line-height: 1.5; }
|
||||||
|
.empty-panel__actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||||
.banner { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.5rem 0.65rem; font-size: 0.78rem; }
|
.banner { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.5rem 0.65rem; font-size: 0.78rem; }
|
||||||
.banner--error { color: var(--color-status-error-text, #ef4444); background: var(--color-status-error-bg, rgba(239,68,68,0.06)); }
|
.banner--error { color: var(--color-status-error-text, #ef4444); background: var(--color-status-error-bg, rgba(239,68,68,0.06)); }
|
||||||
.muted { color: var(--color-text-muted); font-size: 0.74rem; }
|
.muted { color: var(--color-text-muted); font-size: 0.74rem; }
|
||||||
|
.runtime-note { margin: 0; color: var(--color-text-secondary); font-size: 0.74rem; line-height: 1.45; }
|
||||||
|
.runtime-cell { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 0.15rem; }
|
||||||
|
.runtime-caption { color: var(--color-text-muted); font-size: 0.64rem; }
|
||||||
|
|
||||||
/* ── Overview 2-column layout ── */
|
/* Overview 2-column layout */
|
||||||
.overview-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 0.65rem; }
|
.overview-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 0.65rem; }
|
||||||
.overview-main { display: grid; gap: 0.65rem; }
|
.overview-main { display: grid; gap: 0.65rem; }
|
||||||
.overview-side { display: grid; gap: 0.65rem; align-content: start; }
|
.overview-side { display: grid; gap: 0.65rem; align-content: start; }
|
||||||
|
|
||||||
/* ── Blocker list ── */
|
/* Blocker list */
|
||||||
.blocker-list { display: grid; gap: 0.3rem; }
|
.blocker-list { display: grid; gap: 0.3rem; }
|
||||||
.blocker-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.76rem; }
|
.blocker-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.76rem; }
|
||||||
|
|
||||||
/* ── Readiness mini ── */
|
/* Readiness mini */
|
||||||
.readiness-mini { display: flex; gap: 0.75rem; font-size: 0.78rem; font-weight: 600; }
|
.readiness-mini { display: flex; gap: 0.75rem; font-size: 0.78rem; font-weight: 600; }
|
||||||
.rm--ok { color: var(--color-status-success, #22c55e); }
|
.rm--ok { color: var(--color-status-success, #22c55e); }
|
||||||
.rm--fail { color: var(--color-status-error, #ef4444); }
|
.rm--fail { color: var(--color-status-error, #ef4444); }
|
||||||
.rm--pend { color: var(--color-status-warning, #f59e0b); }
|
.rm--pend { color: var(--color-status-warning, #f59e0b); }
|
||||||
|
|
||||||
/* ── Health circle ── */
|
/* Health circle */
|
||||||
.health-circle-panel { display: flex; justify-content: center; padding: 1rem; }
|
.health-circle-panel { display: flex; justify-content: center; padding: 1rem; }
|
||||||
.health-circle {
|
.health-circle {
|
||||||
width: 80px; height: 80px; border-radius: 50%;
|
width: 80px; height: 80px; border-radius: 50%;
|
||||||
@@ -448,18 +625,18 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
.health-circle__num { font-size: 1.1rem; font-weight: 700; line-height: 1; }
|
.health-circle__num { font-size: 1.1rem; font-weight: 700; line-height: 1; }
|
||||||
.health-circle__label { font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
.health-circle__label { font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
|
||||||
/* ── Quick stats ── */
|
/* Quick stats */
|
||||||
.quick-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.35rem; text-align: center; }
|
.quick-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.35rem; text-align: center; }
|
||||||
.qs { display: flex; flex-direction: column; align-items: center; }
|
.qs { display: flex; flex-direction: column; align-items: center; }
|
||||||
.qs__v { font-size: 1rem; font-weight: 700; color: var(--color-text-heading); }
|
.qs__v { font-size: 1rem; font-weight: 700; color: var(--color-text-heading); }
|
||||||
.qs__l { font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
.qs__l { font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
|
||||||
/* ── Gate grid ── */
|
/* Gate grid */
|
||||||
.gg-wrap { overflow-x: auto; }
|
.gg-wrap { overflow-x: auto; }
|
||||||
.th-gate, .td-gate { text-align: center; font-size: 0.68rem; }
|
.th-gate, .td-gate { text-align: center; font-size: 0.68rem; }
|
||||||
.th-gate { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
.th-gate { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||||
|
|
||||||
/* ── Evidence grid ── */
|
/* Evidence grid */
|
||||||
.evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.5rem; }
|
.evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.5rem; }
|
||||||
.ev-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.55rem; display: grid; gap: 0.3rem; }
|
.ev-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.55rem; display: grid; gap: 0.3rem; }
|
||||||
.ev-card__hdr { display: flex; justify-content: space-between; align-items: center; }
|
.ev-card__hdr { display: flex; justify-content: space-between; align-items: center; }
|
||||||
@@ -470,12 +647,30 @@ function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' {
|
|||||||
.ev-card__date { font-size: 0.66rem; color: var(--color-text-muted); }
|
.ev-card__date { font-size: 0.66rem; color: var(--color-text-muted); }
|
||||||
.ev-card__foot { display: flex; gap: 0.3rem; }
|
.ev-card__foot { display: flex; gap: 0.3rem; }
|
||||||
|
|
||||||
/* ── Drift ── */
|
/* Drift */
|
||||||
.drift-alert { padding: 0.5rem; background: var(--color-status-warning-bg, rgba(245,158,11,0.08)); border-radius: var(--radius-sm); }
|
.drift-alert { padding: 0.5rem; background: var(--color-status-warning-bg, rgba(245,158,11,0.08)); border-radius: var(--radius-sm); }
|
||||||
.drift-alert__msg { display: flex; align-items: flex-start; gap: 0.5rem; }
|
.drift-alert__msg { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||||
.drift-alert__msg p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); }
|
.drift-alert__msg p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); }
|
||||||
.drift-ok { display: flex; align-items: center; gap: 0.5rem; }
|
.drift-ok { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
.drift-ok p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); }
|
.drift-ok p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); }
|
||||||
|
.runtime-summary { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||||
|
.runtime-summary__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.runtime-summary__item--verified { background: var(--color-status-success-bg, rgba(34,197,94,0.08)); color: var(--color-status-success-text, #15803d); }
|
||||||
|
.runtime-summary__item--drift { background: var(--color-status-warning-bg, rgba(245,158,11,0.1)); color: var(--color-status-warning-text, #b45309); }
|
||||||
|
.runtime-summary__item--offline { background: var(--color-status-error-bg, rgba(239,68,68,0.08)); color: var(--color-status-error-text, #b91c1c); }
|
||||||
|
.runtime-summary__item--unmonitored { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
|
||||||
|
.runtime-matrix-wrap { overflow-x: auto; }
|
||||||
|
.runtime-row--drift { background: var(--color-status-warning-bg, rgba(245,158,11,0.1)); }
|
||||||
|
.runtime-row--offline { background: var(--color-status-error-bg, rgba(239,68,68,0.08)); }
|
||||||
|
.runtime-row--unmonitored { background: var(--color-surface-secondary); }
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.overview-layout { grid-template-columns: 1fr; }
|
.overview-layout { grid-template-columns: 1fr; }
|
||||||
@@ -488,6 +683,8 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
private readonly topologySetup = inject(TopologySetupClient);
|
private readonly topologySetup = inject(TopologySetupClient);
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly helperCtx = inject(StellaHelperContextService);
|
||||||
readonly context = inject(PlatformContextStore);
|
readonly context = inject(PlatformContextStore);
|
||||||
|
|
||||||
readonly gateNames = GATE_NAMES;
|
readonly gateNames = GATE_NAMES;
|
||||||
@@ -514,20 +711,53 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
readonly currentRelease = signal<string | null>(null);
|
readonly currentRelease = signal<string | null>(null);
|
||||||
readonly validating = signal<Set<string>>(new Set());
|
readonly validating = signal<Set<string>>(new Set());
|
||||||
readonly validatingAll = signal(false);
|
readonly validatingAll = signal(false);
|
||||||
|
readonly runtimeSectionExpanded = signal(true);
|
||||||
|
|
||||||
// ── Lookup maps ──
|
// Lookup maps
|
||||||
|
readonly hostMap = computed(() => new Map(this.hostRows().map(h => [h.hostId, h])));
|
||||||
readonly hostNameMap = computed(() => new Map(this.hostRows().map(h => [h.hostId, h.hostName])));
|
readonly hostNameMap = computed(() => new Map(this.hostRows().map(h => [h.hostId, h.hostName])));
|
||||||
readonly agentNameMap = computed(() => new Map(this.agentRows().map(a => [a.agentId, a.agentName])));
|
readonly agentNameMap = computed(() => new Map(this.agentRows().map(a => [a.agentId, a.agentName])));
|
||||||
readonly targetNameMap = computed(() => new Map(this.targetRows().map(t => [t.targetId, t.name])));
|
readonly targetNameMap = computed(() => new Map(this.targetRows().map(t => [t.targetId, t.name])));
|
||||||
readonly envNameMap = computed(() => new Map(this.allEnvironments().map(e => [e.environmentId, e.displayName])));
|
readonly envNameMap = computed(() => new Map(this.allEnvironments().map(e => [e.environmentId, e.displayName])));
|
||||||
|
|
||||||
// ── Health computeds ──
|
// Health computeds
|
||||||
readonly healthyTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'healthy').length);
|
readonly healthyTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'healthy').length);
|
||||||
readonly degradedTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'degraded').length);
|
readonly degradedTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'degraded').length);
|
||||||
readonly unhealthyTargets = computed(() => this.targetRows().filter(t => { const s = t.healthStatus.trim().toLowerCase(); return s === 'unhealthy' || s === 'offline' || s === 'unknown'; }).length);
|
readonly unhealthyTargets = computed(() => this.targetRows().filter(t => { const s = t.healthStatus.trim().toLowerCase(); return s === 'unhealthy' || s === 'offline' || s === 'unknown'; }).length);
|
||||||
|
readonly unknownHealthTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'unknown').length);
|
||||||
readonly blockingFindings = computed(() => this.findingRows().filter(f => f.effectiveDisposition.trim().toLowerCase() === 'action_required').length);
|
readonly blockingFindings = computed(() => this.findingRows().filter(f => f.effectiveDisposition.trim().toLowerCase() === 'action_required').length);
|
||||||
readonly staleCapsules = computed(() => this.capsuleRows().filter(c => c.status.trim().toLowerCase().includes('stale')).length);
|
readonly staleCapsules = computed(() => this.capsuleRows().filter(c => c.status.trim().toLowerCase().includes('stale')).length);
|
||||||
readonly degradedAgents = computed(() => this.agentRows().filter(a => a.status.trim().toLowerCase() !== 'active').length);
|
readonly degradedAgents = computed(() => this.agentRows().filter(a => a.status.trim().toLowerCase() !== 'active').length);
|
||||||
|
readonly helperContexts = computed(() => {
|
||||||
|
const contexts: string[] = [];
|
||||||
|
if (this.blockingFindings() > 0) {
|
||||||
|
contexts.push('critical-open');
|
||||||
|
}
|
||||||
|
if (this.failingTargetsCnt() > 0) {
|
||||||
|
contexts.push('gate-blocked');
|
||||||
|
}
|
||||||
|
if (this.unknownHealthTargets() > 0) {
|
||||||
|
contexts.push('health-unknown');
|
||||||
|
}
|
||||||
|
if (this.degradedAgents() > 0) {
|
||||||
|
contexts.push('agents-degraded');
|
||||||
|
}
|
||||||
|
if (!this.loading() && this.agentRows().length === 0) {
|
||||||
|
contexts.push('agents-none');
|
||||||
|
}
|
||||||
|
if (!this.loading()) {
|
||||||
|
if (this.activeTab() === 'targets' && this.targetRows().length === 0) {
|
||||||
|
contexts.push('empty-table');
|
||||||
|
}
|
||||||
|
if (this.activeTab() === 'readiness' && this.readinessReports().length === 0) {
|
||||||
|
contexts.push('empty-table');
|
||||||
|
}
|
||||||
|
if (this.activeTab() === 'agents' && this.agentRows().length === 0) {
|
||||||
|
contexts.push('empty-table');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contexts;
|
||||||
|
});
|
||||||
|
|
||||||
readonly deployHealth = computed(() => {
|
readonly deployHealth = computed(() => {
|
||||||
if (this.unhealthyTargets() > 0) return 'UNHEALTHY';
|
if (this.unhealthyTargets() > 0) return 'UNHEALTHY';
|
||||||
@@ -540,12 +770,12 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
return h === 'HEALTHY' ? 'success' : h === 'DEGRADED' ? 'warning' : 'error';
|
return h === 'HEALTHY' ? 'success' : h === 'DEGRADED' ? 'warning' : 'error';
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Readiness computeds ──
|
// Readiness computeds
|
||||||
readonly readyTargetsCnt = computed(() => this.readinessReports().filter(r => r.isReady).length);
|
readonly readyTargetsCnt = computed(() => this.readinessReports().filter(r => r.isReady).length);
|
||||||
readonly failingTargetsCnt = computed(() => this.readinessReports().filter(r => !r.isReady && r.gates.some(g => g.status === 'fail')).length);
|
readonly failingTargetsCnt = computed(() => this.readinessReports().filter(r => !r.isReady && r.gates.some(g => g.status === 'fail')).length);
|
||||||
readonly pendingTargetsCnt = computed(() => this.readinessReports().filter(r => !r.isReady && r.gates.some(g => g.status === 'pending') && !r.gates.some(g => g.status === 'fail')).length);
|
readonly pendingTargetsCnt = computed(() => this.readinessReports().filter(r => !r.isReady && r.gates.some(g => g.status === 'pending') && !r.gates.some(g => g.status === 'fail')).length);
|
||||||
|
|
||||||
// ── Drift ──
|
// Drift
|
||||||
readonly driftDetected = computed(() => {
|
readonly driftDetected = computed(() => {
|
||||||
const targets = this.targetRows();
|
const targets = this.targetRows();
|
||||||
if (targets.length < 2) return false;
|
if (targets.length < 2) return false;
|
||||||
@@ -562,7 +792,30 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
return targets.length - maxCount;
|
return targets.length - maxCount;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Promotion context ──
|
// Promotion context
|
||||||
|
readonly expectedReleaseVersion = computed(() => dominantReleaseVersion(this.targetRows()));
|
||||||
|
readonly runtimeVerificationRows = computed(() =>
|
||||||
|
this.targetRows().map((target) => ({
|
||||||
|
target,
|
||||||
|
summary: buildRuntimeVerificationSummary(
|
||||||
|
target,
|
||||||
|
this.hostMap().get(target.hostId),
|
||||||
|
this.expectedReleaseVersion(),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
readonly runtimeVerifiedCount = computed(() =>
|
||||||
|
this.runtimeVerificationRows().filter(row => row.summary.status === 'verified').length,
|
||||||
|
);
|
||||||
|
readonly runtimeDriftCount = computed(() =>
|
||||||
|
this.runtimeVerificationRows().filter(row => row.summary.status === 'drift').length,
|
||||||
|
);
|
||||||
|
readonly runtimeOfflineCount = computed(() =>
|
||||||
|
this.runtimeVerificationRows().filter(row => row.summary.status === 'offline').length,
|
||||||
|
);
|
||||||
|
readonly runtimeUnmonitoredCount = computed(() =>
|
||||||
|
this.runtimeVerificationRows().filter(row => row.summary.status === 'not_monitored').length,
|
||||||
|
);
|
||||||
readonly promotionLine = computed(() => {
|
readonly promotionLine = computed(() => {
|
||||||
const envId = this.environmentId();
|
const envId = this.environmentId();
|
||||||
const paths = this.promotionPaths();
|
const paths = this.promotionPaths();
|
||||||
@@ -571,13 +824,13 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
const upstream = paths.filter(p => p.targetEnvironmentId === envId).map(p => names.get(p.sourceEnvironmentId) ?? p.sourceEnvironmentId);
|
const upstream = paths.filter(p => p.targetEnvironmentId === envId).map(p => names.get(p.sourceEnvironmentId) ?? p.sourceEnvironmentId);
|
||||||
const downstream = paths.filter(p => p.sourceEnvironmentId === envId).map(p => names.get(p.targetEnvironmentId) ?? p.targetEnvironmentId);
|
const downstream = paths.filter(p => p.sourceEnvironmentId === envId).map(p => names.get(p.targetEnvironmentId) ?? p.targetEnvironmentId);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (upstream.length) parts.push(upstream.join(', ') + ' →');
|
if (upstream.length) parts.push(upstream.join(', ') + ' ->');
|
||||||
parts.push(`[${this.environmentLabel()}]`);
|
parts.push(`[${this.environmentLabel()}]`);
|
||||||
if (downstream.length) parts.push('→ ' + downstream.join(', '));
|
if (downstream.length) parts.push('-> ' + downstream.join(', '));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Blockers ──
|
// Blockers
|
||||||
readonly blockers = computed(() => {
|
readonly blockers = computed(() => {
|
||||||
const items: { severity: 'error' | 'warning'; text: string }[] = [];
|
const items: { severity: 'error' | 'warning'; text: string }[] = [];
|
||||||
if (this.unhealthyTargets() > 0) items.push({ severity: 'error', text: `${this.unhealthyTargets()} unhealthy target(s) require runtime remediation.` });
|
if (this.unhealthyTargets() > 0) items.push({ severity: 'error', text: `${this.unhealthyTargets()} unhealthy target(s) require runtime remediation.` });
|
||||||
@@ -588,7 +841,7 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Tabs (dynamic status dots + badges) ──
|
// Tabs (dynamic status dots + badges)
|
||||||
readonly tabDefs = computed((): StellaPageTab[] => [
|
readonly tabDefs = computed((): StellaPageTab[] => [
|
||||||
{ id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', status: this.deployHealthStatus() === 'success' ? 'ok' : this.deployHealthStatus() === 'warning' ? 'warn' : 'error' },
|
{ id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', status: this.deployHealthStatus() === 'success' ? 'ok' : this.deployHealthStatus() === 'warning' ? 'warn' : 'error' },
|
||||||
{ id: 'targets', label: 'Targets', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01', badge: this.targetRows().length || undefined },
|
{ id: 'targets', label: 'Targets', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01', badge: this.targetRows().length || undefined },
|
||||||
@@ -607,13 +860,16 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
{ label: 'Runs', route: '/releases/deployments', description: 'Deployment runs' },
|
{ label: 'Runs', route: '/releases/deployments', description: 'Deployment runs' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Helpers ──
|
// Helpers
|
||||||
hostName(id: string): string { return this.hostNameMap().get(id) ?? id.substring(0, 8) + '...'; }
|
hostName(id: string): string { return this.hostNameMap().get(id) ?? id.substring(0, 8) + '...'; }
|
||||||
agentName(id: string): string { return this.agentNameMap().get(id) ?? id.substring(0, 8) + '...'; }
|
agentName(id: string): string { return this.agentNameMap().get(id) ?? id.substring(0, 8) + '...'; }
|
||||||
targetName(id: string): string { return this.targetNameMap().get(id) ?? id.substring(0, 12); }
|
targetName(id: string): string { return this.targetNameMap().get(id) ?? id.substring(0, 12); }
|
||||||
healthToStatus(h: string): 'success' | 'warning' | 'error' | 'neutral' { return healthToStatus(h); }
|
healthToStatus(h: string): 'success' | 'warning' | 'error' | 'neutral' { return healthToStatus(h); }
|
||||||
severityStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { return severityToStatus(s); }
|
severityStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { return severityToStatus(s); }
|
||||||
fmtGate(g: string): string { return fmtGateName(g); }
|
fmtGate(g: string): string { return fmtGateName(g); }
|
||||||
|
runtimeStatusTone(status: RuntimeVerificationStatus): 'success' | 'warning' | 'error' | 'neutral' { return runtimeVerificationTone(status); }
|
||||||
|
shortId(value: string | null | undefined): string { return shortId(value); }
|
||||||
|
shortDigest(value: string | null | undefined): string { return shortDigest(value); }
|
||||||
|
|
||||||
gateStatus(rpt: ReadinessReport, gateName: string): 'success' | 'error' | 'warning' | 'neutral' {
|
gateStatus(rpt: ReadinessReport, gateName: string): 'success' | 'error' | 'warning' | 'neutral' {
|
||||||
const gate = rpt.gates.find(x => x.gateName === gateName);
|
const gate = rpt.gates.find(x => x.gateName === gateName);
|
||||||
@@ -622,8 +878,8 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
|
|
||||||
gateLabel(rpt: ReadinessReport, gateName: string): string {
|
gateLabel(rpt: ReadinessReport, gateName: string): string {
|
||||||
const gate = rpt.gates.find(x => x.gateName === gateName);
|
const gate = rpt.gates.find(x => x.gateName === gateName);
|
||||||
if (!gate) return '—';
|
if (!gate) return '-';
|
||||||
switch (gate.status) { case 'pass': return '✓'; case 'fail': return '✗'; case 'pending': return '●'; default: return '—'; }
|
switch (gate.status) { case 'pass': return 'OK'; case 'fail': return 'Fail'; case 'pending': return 'Pending'; default: return '-'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
runStatusType(status: string): 'success' | 'warning' | 'error' | 'neutral' {
|
runStatusType(status: string): 'success' | 'warning' | 'error' | 'neutral' {
|
||||||
@@ -634,10 +890,14 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lifecycle ──
|
// Lifecycle
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.context.initialize();
|
this.context.initialize();
|
||||||
|
effect(() => {
|
||||||
|
this.helperCtx.setScope('topology-environment-detail', this.helperContexts());
|
||||||
|
}, { allowSignalWrites: true });
|
||||||
|
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('topology-environment-detail'));
|
||||||
this.route.paramMap.subscribe(params => {
|
this.route.paramMap.subscribe(params => {
|
||||||
const id = params.get('environmentId') ?? '';
|
const id = params.get('environmentId') ?? '';
|
||||||
this.environmentId.set(id);
|
this.environmentId.set(id);
|
||||||
@@ -697,8 +957,14 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
let best = ''; let bestCount = 0;
|
let best = ''; let bestCount = 0;
|
||||||
for (const [v, c] of counts) if (c > bestCount) { best = v; bestCount = c; }
|
for (const [v, c] of counts) if (c > bestCount) { best = v; bestCount = c; }
|
||||||
this.currentRelease.set(best.substring(0, 12));
|
this.currentRelease.set(best.substring(0, 12));
|
||||||
|
} else {
|
||||||
|
this.currentRelease.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.runtimeSectionExpanded.set(
|
||||||
|
this.driftDetected() || this.runtimeOfflineCount() > 0 || this.runtimeUnmonitoredCount() > 0,
|
||||||
|
);
|
||||||
|
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
error: (err: unknown) => {
|
error: (err: unknown) => {
|
||||||
@@ -708,7 +974,7 @@ export class TopologyEnvironmentDetailPageComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Actions ──
|
// Actions
|
||||||
|
|
||||||
validateTarget(targetId: string): void {
|
validateTarget(targetId: string): void {
|
||||||
const s = new Set(this.validating()); s.add(targetId); this.validating.set(s);
|
const s = new Set(this.validating()); s.add(targetId); this.validating.set(s);
|
||||||
|
|||||||
@@ -1,22 +1,698 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { catchError, forkJoin, of, take } from 'rxjs';
|
||||||
|
|
||||||
|
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||||
|
import { RelativeTimePipe } from '../../shared/pipes/format.pipes';
|
||||||
|
import { CopyToClipboardComponent } from '../../shared/ui/copy-to-clipboard/copy-to-clipboard.component';
|
||||||
|
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
|
||||||
|
import { TopologyDataService } from './topology-data.service';
|
||||||
|
import { TopologyHost, TopologyTarget } from './topology.models';
|
||||||
|
import {
|
||||||
|
dominantReleaseVersion,
|
||||||
|
hasRuntimeProbe,
|
||||||
|
HostConnectionProfile,
|
||||||
|
inferConnectionProfile,
|
||||||
|
inferProbeRecommendation,
|
||||||
|
normalizeProbeStatus,
|
||||||
|
probeStatusLabel,
|
||||||
|
probeStatusTone,
|
||||||
|
probeTypeLabel,
|
||||||
|
shortDigest,
|
||||||
|
shortId,
|
||||||
|
} from './topology-runtime.helpers';
|
||||||
|
|
||||||
|
interface HostActivityRow {
|
||||||
|
targetId: string;
|
||||||
|
targetName: string;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
releaseVersionId: string | null;
|
||||||
|
imageDigest: string | null;
|
||||||
|
drifted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-topology-host-detail-page',
|
selector: 'app-topology-host-detail-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
imports: [RouterLink, RelativeTimePipe, CopyToClipboardComponent, StatusBadgeComponent],
|
||||||
template: `
|
template: `
|
||||||
<section class="topology-page">
|
<section class="host-detail">
|
||||||
<header>
|
<header class="hero">
|
||||||
<h1>Host {{ hostId }}</h1>
|
<div class="hero__nav">
|
||||||
<p>Inventory, connectivity tests, mapped targets, and agent diagnostics.</p>
|
<a routerLink="/environments/hosts" class="back-link">Back to Hosts</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (host()) {
|
||||||
|
<div class="hero__main">
|
||||||
|
<div>
|
||||||
|
<div class="hero__title-row">
|
||||||
|
<h1>{{ host()!.hostName }}</h1>
|
||||||
|
<app-status-badge [status]="hostStatusTone(host()!.status)" [label]="host()!.status" [showIcon]="true" size="sm" />
|
||||||
|
<app-status-badge [status]="probeTone()" [label]="probeLabel()" [showIcon]="true" size="sm" />
|
||||||
|
</div>
|
||||||
|
<p class="hero__subtitle">{{ host()!.regionId }} / {{ host()!.environmentId }} / {{ host()!.runtimeType }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero__chips">
|
||||||
|
<span class="chip">Targets {{ hostTargets().length }}</span>
|
||||||
|
<span class="chip">Probe {{ probeType() }}</span>
|
||||||
|
<span class="chip">Last seen {{ lastSeenLabel() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="hero__main">
|
||||||
|
<div>
|
||||||
|
<h1>Host {{ hostId() }}</h1>
|
||||||
|
<p class="hero__subtitle">Waiting for topology data.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<div class="banner banner--error">{{ error() }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading host detail...</div>
|
||||||
|
} @else if (!host()) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No topology host matched {{ hostId() }} in the current scope.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="layout">
|
||||||
|
<article class="card">
|
||||||
|
<h2>Host Overview</h2>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-grid__label">Host ID</span>
|
||||||
|
<span class="meta-grid__value mono">{{ host()!.hostId }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Region</span>
|
||||||
|
<span class="meta-grid__value">{{ host()!.regionId }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Environment</span>
|
||||||
|
<span class="meta-grid__value">{{ host()!.environmentId }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Runtime</span>
|
||||||
|
<span class="meta-grid__value">{{ host()!.runtimeType }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Agent</span>
|
||||||
|
<span class="meta-grid__value">{{ host()!.agentId || 'Not assigned' }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Last seen</span>
|
||||||
|
<span class="meta-grid__value">{{ host()!.lastSeenAt ? (host()!.lastSeenAt | relativeTime) : 'No host heartbeat yet' }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<h2>Connection Profile</h2>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-grid__label">Connection</span>
|
||||||
|
<span class="meta-grid__value">{{ connectionProfile().connectionLabel }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Endpoint</span>
|
||||||
|
<span class="meta-grid__value">{{ connectionProfile().endpointLabel }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Port / Profile</span>
|
||||||
|
<span class="meta-grid__value">{{ connectionProfile().portLabel }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Credential ref</span>
|
||||||
|
<span class="meta-grid__value">Not exposed by topology API</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Connection test</span>
|
||||||
|
<span class="meta-grid__value">Use mapped target validation until host-level test endpoints exist.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="summary">{{ connectionProfile().summary }}</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a [routerLink]="['/environments', 'targets']" [queryParams]="{ hostId: host()!.hostId }">Open mapped targets</a>
|
||||||
|
<a [routerLink]="['/ops/operations/agents']" [queryParams]="{ agentId: host()!.agentId }">Open agent</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card card--wide">
|
||||||
|
<div class="card__header">
|
||||||
|
<div>
|
||||||
|
<h2>Mapped Targets</h2>
|
||||||
|
<p>{{ hostTargets().length }} target(s) currently mapped to this host.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="stella-table stella-table--striped stella-table--hoverable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Release</th>
|
||||||
|
<th>Image Digest</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (target of hostTargets(); track target.targetId) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ target.name }}</td>
|
||||||
|
<td>{{ target.targetType }}</td>
|
||||||
|
<td>
|
||||||
|
<app-status-badge [status]="hostStatusTone(target.healthStatus)" [label]="target.healthStatus" [showIcon]="true" size="sm" />
|
||||||
|
</td>
|
||||||
|
<td class="mono" [title]="target.releaseVersionId">{{ shortId(target.releaseVersionId) }}</td>
|
||||||
|
<td class="mono" [title]="target.imageDigest">{{ shortDigest(target.imageDigest) }}</td>
|
||||||
|
<td><a [routerLink]="['/environments/targets', target.targetId]" class="link">Inspect</a></td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="muted">No mapped targets for this host.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div>
|
||||||
|
<h2>Runtime Probe</h2>
|
||||||
|
<p>{{ probePanelSummary() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!hasProbe()) {
|
||||||
|
<div class="probe-empty">
|
||||||
|
<p>No runtime probe heartbeat is currently bound to this host.</p>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-grid__label">Recommended probe</span>
|
||||||
|
<span class="meta-grid__value">{{ probeType() }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Command shell</span>
|
||||||
|
<span class="meta-grid__value">{{ connectionProfile().shell }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" [checked]="enableRuntimeVerification()" (change)="enableRuntimeVerification.set(!enableRuntimeVerification())" />
|
||||||
|
<span>Include runtime verification flag in install command preview</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="command-block">
|
||||||
|
<code>{{ installCommand() }}</code>
|
||||||
|
<app-copy-to-clipboard [value]="installCommand()" ariaLabel="Copy host install command" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="meta-grid">
|
||||||
|
<span class="meta-grid__label">Probe status</span>
|
||||||
|
<span class="meta-grid__value">
|
||||||
|
<app-status-badge [status]="probeTone()" [label]="probeLabel()" [showIcon]="true" size="sm" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Probe type</span>
|
||||||
|
<span class="meta-grid__value">{{ probeType() }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Last heartbeat</span>
|
||||||
|
<span class="meta-grid__value">{{ probeLastHeartbeatLabel() }}</span>
|
||||||
|
|
||||||
|
<span class="meta-grid__label">Coverage proxy</span>
|
||||||
|
<span class="meta-grid__value">{{ hostTargets().length }} mapped target(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="summary">
|
||||||
|
Exact probe health metrics such as CPU overhead, buffer utilization, and events per second are not yet exposed by the topology read model.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div>
|
||||||
|
<h2>Recent Activity</h2>
|
||||||
|
<p>Latest topology sync rows derived from mapped targets.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="stella-table stella-table--striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Last Sync</th>
|
||||||
|
<th>Release</th>
|
||||||
|
<th>Digest</th>
|
||||||
|
<th>Drift</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of recentActivity(); track row.targetId) {
|
||||||
|
<tr [class.activity-row--drift]="row.drifted">
|
||||||
|
<td>{{ row.targetName }}</td>
|
||||||
|
<td>{{ row.lastSyncAt ? (row.lastSyncAt | relativeTime) : 'Never' }}</td>
|
||||||
|
<td class="mono" [title]="row.releaseVersionId">{{ shortId(row.releaseVersionId) }}</td>
|
||||||
|
<td class="mono" [title]="row.imageDigest">{{ shortDigest(row.imageDigest) }}</td>
|
||||||
|
<td>
|
||||||
|
<app-status-badge
|
||||||
|
[status]="row.drifted ? 'warning' : 'success'"
|
||||||
|
[label]="row.drifted ? 'Drifted' : 'In sync'"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="muted">No target activity for this host yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
`,
|
`,
|
||||||
|
styles: [`
|
||||||
|
.host-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: var(--color-text-link);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title-row h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__subtitle {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-surface-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner--error {
|
||||||
|
color: var(--color-status-error-text);
|
||||||
|
background: var(--color-status-error-bg);
|
||||||
|
border-color: var(--color-status-error-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty-state {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--color-card-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__header p {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.25rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid__label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid__value {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions a,
|
||||||
|
.link {
|
||||||
|
color: var(--color-text-link);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.73rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions a:hover,
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-empty {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-empty p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-block {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: start;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface-secondary);
|
||||||
|
padding: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-block code,
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row--drift {
|
||||||
|
background: var(--color-status-warning-bg, rgba(245, 158, 11, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--wide {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class TopologyHostDetailPageComponent {
|
export class TopologyHostDetailPageComponent {
|
||||||
|
private readonly topologyApi = inject(TopologyDataService);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
readonly hostId = this.route.snapshot.paramMap.get('hostId') ?? 'unknown';
|
|
||||||
|
readonly context = inject(PlatformContextStore);
|
||||||
|
|
||||||
|
readonly hostId = signal('');
|
||||||
|
readonly loading = signal(true);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
readonly enableRuntimeVerification = signal(true);
|
||||||
|
|
||||||
|
readonly hosts = signal<TopologyHost[]>([]);
|
||||||
|
readonly targets = signal<TopologyTarget[]>([]);
|
||||||
|
|
||||||
|
readonly host = computed(() => this.hosts().find((item) => item.hostId === this.hostId()) ?? null);
|
||||||
|
|
||||||
|
readonly hostTargets = computed(() => {
|
||||||
|
const host = this.host();
|
||||||
|
if (!host) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.targets()
|
||||||
|
.filter((item) => item.hostId === host.hostId)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly dominantHostRelease = computed(() => dominantReleaseVersion(this.hostTargets()));
|
||||||
|
|
||||||
|
readonly recentActivity = computed<HostActivityRow[]>(() => {
|
||||||
|
const expectedRelease = this.dominantHostRelease();
|
||||||
|
|
||||||
|
return this.hostTargets()
|
||||||
|
.map((target) => ({
|
||||||
|
targetId: target.targetId,
|
||||||
|
targetName: target.name,
|
||||||
|
lastSyncAt: target.lastSyncAt,
|
||||||
|
releaseVersionId: target.releaseVersionId || null,
|
||||||
|
imageDigest: target.imageDigest || null,
|
||||||
|
drifted:
|
||||||
|
!!expectedRelease &&
|
||||||
|
!!target.releaseVersionId &&
|
||||||
|
target.releaseVersionId.trim().length > 0 &&
|
||||||
|
target.releaseVersionId !== expectedRelease,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aTime = a.lastSyncAt ? Date.parse(a.lastSyncAt) : 0;
|
||||||
|
const bTime = b.lastSyncAt ? Date.parse(b.lastSyncAt) : 0;
|
||||||
|
return bTime - aTime;
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly connectionProfile = computed<HostConnectionProfile>(() => {
|
||||||
|
const host = this.host();
|
||||||
|
if (!host) {
|
||||||
|
return inferConnectionProfile('-', 'docker_host');
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferConnectionProfile(host.hostName, host.runtimeType);
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly hasProbe = computed(() => hasRuntimeProbe(this.host()));
|
||||||
|
|
||||||
|
readonly probeTone = computed(() => {
|
||||||
|
const host = this.host();
|
||||||
|
return probeStatusTone(normalizeProbeStatus(host?.probeStatus));
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly probeLabel = computed(() => {
|
||||||
|
const host = this.host();
|
||||||
|
return probeStatusLabel(normalizeProbeStatus(host?.probeStatus));
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly probeType = computed(() => {
|
||||||
|
const host = this.host();
|
||||||
|
const fallbackProbe = inferProbeRecommendation(host?.runtimeType ?? this.connectionProfile().connectionLabel);
|
||||||
|
return probeTypeLabel(host?.probeType ?? fallbackProbe);
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly probePanelSummary = computed(() => {
|
||||||
|
if (!this.hasProbe()) {
|
||||||
|
return 'Install guidance is shown because no probe heartbeat is currently attached to this host.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${this.probeType()} probe state is ${this.probeLabel().toLowerCase()}.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly installCommand = computed(() => {
|
||||||
|
const host = this.host();
|
||||||
|
const hostToken = host?.hostName ?? '<hostname>';
|
||||||
|
const includeVerificationFlag = this.enableRuntimeVerification();
|
||||||
|
|
||||||
|
if (this.connectionProfile().shell === 'powershell') {
|
||||||
|
const verificationSegment = includeVerificationFlag ? "$env:STELLA_RUNTIME_VERIFICATION='1'; " : '';
|
||||||
|
return `$env:STELLA_HOST='${hostToken}'; $env:STELLA_TOKEN='<token>'; ${verificationSegment}Invoke-WebRequest -UseBasicParsing https://stella-ops.local/api/v1/agents/install.ps1 | Invoke-Expression`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationSegment = includeVerificationFlag ? ' STELLA_RUNTIME_VERIFICATION=1' : '';
|
||||||
|
return `curl -sSL https://stella-ops.local/api/v1/agents/install.sh | STELLA_HOST=${hostToken} STELLA_TOKEN=<token>${verificationSegment} bash`;
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.context.initialize();
|
||||||
|
|
||||||
|
this.route.paramMap.subscribe((params) => {
|
||||||
|
const hostId = params.get('hostId') ?? '';
|
||||||
|
this.hostId.set(hostId);
|
||||||
|
if (hostId) {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
shortId(value: string | null | undefined): string {
|
||||||
|
return shortId(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
shortDigest(value: string | null | undefined): string {
|
||||||
|
return shortDigest(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
hostStatusTone(status: string): 'success' | 'warning' | 'error' | 'neutral' {
|
||||||
|
const normalized = status.trim().toLowerCase();
|
||||||
|
if (normalized === 'healthy') {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
if (normalized === 'degraded') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
if (normalized === 'offline' || normalized === 'unhealthy') {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSeenLabel(): string {
|
||||||
|
const currentHost = this.host();
|
||||||
|
if (!currentHost?.lastSeenAt) {
|
||||||
|
return 'No host heartbeat yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RelativeTimePipe().transform(currentHost.lastSeenAt) || currentHost.lastSeenAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
probeLastHeartbeatLabel(): string {
|
||||||
|
const currentHost = this.host();
|
||||||
|
const value = currentHost?.probeLastHeartbeat ?? currentHost?.lastSeenAt;
|
||||||
|
if (!value) {
|
||||||
|
return 'No probe heartbeat yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RelativeTimePipe().transform(value) || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
|
||||||
|
forkJoin({
|
||||||
|
hosts: this.topologyApi.list<TopologyHost>('/api/v2/topology/hosts', this.context).pipe(catchError(() => of([]))),
|
||||||
|
targets: this.topologyApi.list<TopologyTarget>('/api/v2/topology/targets', this.context).pipe(catchError(() => of([]))),
|
||||||
|
})
|
||||||
|
.pipe(take(1))
|
||||||
|
.subscribe({
|
||||||
|
next: ({ hosts, targets }) => {
|
||||||
|
this.hosts.set(hosts);
|
||||||
|
this.targets.set(targets);
|
||||||
|
|
||||||
|
if (!hosts.some((item) => item.hostId === this.hostId())) {
|
||||||
|
this.error.set(`Host ${this.hostId()} was not found in the current topology scope.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err: unknown) => {
|
||||||
|
this.error.set(err instanceof Error ? err.message : 'Failed to load host detail.');
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { catchError, forkJoin, of, take } from 'rxjs';
|
import { catchError, forkJoin, of, take } from 'rxjs';
|
||||||
|
|
||||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||||
|
import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service';
|
||||||
|
import { RelativeTimePipe } from '../../shared/pipes/format.pipes';
|
||||||
|
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
|
||||||
import { TopologyDataService } from './topology-data.service';
|
import { TopologyDataService } from './topology-data.service';
|
||||||
import { TopologyHost, TopologyTarget } from './topology.models';
|
import { TopologyHost, TopologyTarget } from './topology.models';
|
||||||
|
import {
|
||||||
|
hasRuntimeProbe,
|
||||||
|
inferConnectionProfile,
|
||||||
|
normalizeProbeStatus,
|
||||||
|
probeStatusLabel,
|
||||||
|
probeStatusTone,
|
||||||
|
probeTypeLabel,
|
||||||
|
} from './topology-runtime.helpers';
|
||||||
|
|
||||||
|
type HostStatusFilter = 'all' | 'healthy' | 'degraded' | 'offline' | 'unknown';
|
||||||
|
type ProbeFilter = 'all' | 'monitored' | 'unmonitored';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-topology-hosts-page',
|
selector: 'app-topology-hosts-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FormsModule, RouterLink],
|
imports: [FormsModule, RouterLink, StatusBadgeComponent],
|
||||||
template: `
|
template: `
|
||||||
<section class="hosts">
|
<section class="hosts">
|
||||||
<header class="hosts__header">
|
<header class="hosts__header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Hosts</h1>
|
<h1>Hosts</h1>
|
||||||
<p>Operational host inventory with runtime, heartbeat, and target mapping.</p>
|
<p>Operational host inventory with runtime probe coverage, heartbeat freshness, and target mapping.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hosts__scope">
|
<div class="hosts__scope">
|
||||||
<span>{{ context.regionSummary() }}</span>
|
<span>{{ context.regionSummary() }}</span>
|
||||||
@@ -39,7 +53,7 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filters__item">
|
<div class="filters__item">
|
||||||
<label for="hosts-status">Status</label>
|
<label for="hosts-status">Host Status</label>
|
||||||
<select id="hosts-status" [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)">
|
<select id="hosts-status" [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)">
|
||||||
<option value="all">All</option>
|
<option value="all">All</option>
|
||||||
<option value="healthy">Healthy</option>
|
<option value="healthy">Healthy</option>
|
||||||
@@ -48,6 +62,14 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
<option value="unknown">Unknown</option>
|
<option value="unknown">Unknown</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filters__item">
|
||||||
|
<label for="hosts-probe">Runtime Probe</label>
|
||||||
|
<select id="hosts-probe" [ngModel]="probeFilter()" (ngModelChange)="probeFilter.set($event)">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="monitored">Monitored</option>
|
||||||
|
<option value="unmonitored">Not monitored</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
@@ -56,15 +78,31 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="skeleton-table">
|
<div class="skeleton-table">
|
||||||
<div class="skeleton-row" style="width:100%"><div class="skeleton-line skeleton-line--title"></div></div>
|
<div class="skeleton-row" style="width: 100%">
|
||||||
<div class="skeleton-row"><div class="skeleton-line"></div><div class="skeleton-line skeleton-line--short"></div></div>
|
<div class="skeleton-line skeleton-line--title"></div>
|
||||||
<div class="skeleton-row"><div class="skeleton-line"></div><div class="skeleton-line skeleton-line--short"></div></div>
|
</div>
|
||||||
<div class="skeleton-row"><div class="skeleton-line"></div><div class="skeleton-line skeleton-line--short"></div></div>
|
<div class="skeleton-row">
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-row">
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-row">
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<section class="split">
|
<section class="split">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<h2>Hosts</h2>
|
<div class="card__header">
|
||||||
|
<div>
|
||||||
|
<h2>Hosts</h2>
|
||||||
|
<p>{{ monitoredHostsCount() }} monitored of {{ hosts().length }} in scope</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="stella-table stella-table--hoverable">
|
<table class="stella-table stella-table--hoverable">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -74,24 +112,53 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
<th>Environment</th>
|
<th>Environment</th>
|
||||||
<th>Runtime</th>
|
<th>Runtime</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Runtime Probe</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
<th>Targets</th>
|
<th>Targets</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (host of filteredHosts(); track host.hostId) {
|
@for (host of filteredHosts(); track host.hostId) {
|
||||||
<tr [class.active]="selectedHostId() === host.hostId" (click)="selectedHostId.set(host.hostId)">
|
<tr [class.active]="selectedHostId() === host.hostId" (click)="selectedHostId.set(host.hostId)">
|
||||||
<td class="cell-name">{{ host.hostName }}</td>
|
<td class="cell-host">
|
||||||
|
<div class="cell-host__main">
|
||||||
|
<a [routerLink]="hostDetailLink(host.hostId)" class="cell-host__link">{{ host.hostName }}</a>
|
||||||
|
<span class="cell-host__meta">{{ host.hostId }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>{{ host.regionId }}</td>
|
<td>{{ host.regionId }}</td>
|
||||||
<td>{{ host.environmentId }}</td>
|
<td>{{ host.environmentId }}</td>
|
||||||
<td>{{ host.runtimeType }}</td>
|
<td>{{ host.runtimeType }}</td>
|
||||||
<td><span class="status-badge" [class.status-badge--healthy]="host.status.toLowerCase() === 'healthy'" [class.status-badge--degraded]="host.status.toLowerCase() === 'degraded'" [class.status-badge--unhealthy]="host.status.toLowerCase() === 'offline'" [class.status-badge--muted]="host.status.toLowerCase() === 'unknown'">{{ host.status }}</span></td>
|
<td>
|
||||||
|
<app-status-badge
|
||||||
|
[status]="hostStatusTone(host.status)"
|
||||||
|
[label]="host.status"
|
||||||
|
[showIcon]="true"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="probe-cell" [title]="probeTooltip(host)">
|
||||||
|
<app-status-badge
|
||||||
|
[status]="probeTone(host)"
|
||||||
|
[label]="probeLabel(host)"
|
||||||
|
[showIcon]="true"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
@if (showProbeType(host)) {
|
||||||
|
<span class="probe-cell__type">{{ probeType(host) }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ lastSeenLabel(host) }}</td>
|
||||||
<td>{{ host.targetCount }}</td>
|
<td>{{ host.targetCount }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr><td colspan="6" class="empty-cell">
|
<tr>
|
||||||
<svg class="empty-cell__icon" viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
<td colspan="8" class="empty-cell">
|
||||||
No hosts for current filters.
|
No hosts for current filters.
|
||||||
</td></tr>
|
</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -101,24 +168,52 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
<article class="card detail">
|
<article class="card detail">
|
||||||
<h2>Selected Host</h2>
|
<h2>Selected Host</h2>
|
||||||
@if (selectedHost()) {
|
@if (selectedHost()) {
|
||||||
<p class="detail__name"><strong>{{ selectedHost()!.hostName }}</strong></p>
|
<div class="detail__hero">
|
||||||
<div class="detail__grid">
|
<div>
|
||||||
<span class="detail__label">Status</span><span class="detail__value"><span class="status-badge" [class.status-badge--healthy]="selectedHost()!.status.toLowerCase() === 'healthy'" [class.status-badge--degraded]="selectedHost()!.status.toLowerCase() === 'degraded'" [class.status-badge--unhealthy]="selectedHost()!.status.toLowerCase() === 'offline'" [class.status-badge--muted]="selectedHost()!.status.toLowerCase() === 'unknown'">{{ selectedHost()!.status }}</span></span>
|
<p class="detail__name">{{ selectedHost()!.hostName }}</p>
|
||||||
<span class="detail__label">Runtime</span><span class="detail__value">{{ selectedHost()!.runtimeType }}</span>
|
<p class="detail__meta">{{ selectedHost()!.regionId }} / {{ selectedHost()!.environmentId }}</p>
|
||||||
<span class="detail__label">Agent</span><span class="detail__value">{{ selectedHost()!.agentId }}</span>
|
</div>
|
||||||
<span class="detail__label">Last seen</span><span class="detail__value">{{ selectedHost()!.lastSeenAt ?? '-' }}</span>
|
<app-status-badge
|
||||||
<span class="detail__label">Impacted targets</span><span class="detail__value">{{ selectedHostTargets().length }}</span>
|
[status]="probeTone(selectedHost()!)"
|
||||||
<span class="detail__label">Upgrade window</span><span class="detail__value">Fri 23:00 UTC</span>
|
[label]="probeLabel(selectedHost()!)"
|
||||||
|
[showIcon]="true"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="detail__grid">
|
||||||
|
<span class="detail__label">Runtime</span>
|
||||||
|
<span class="detail__value">{{ selectedHost()!.runtimeType }}</span>
|
||||||
|
|
||||||
|
<span class="detail__label">Host status</span>
|
||||||
|
<span class="detail__value">{{ selectedHost()!.status }}</span>
|
||||||
|
|
||||||
|
<span class="detail__label">Connection</span>
|
||||||
|
<span class="detail__value">{{ selectedConnectionProfile().connectionLabel }}</span>
|
||||||
|
|
||||||
|
<span class="detail__label">Endpoint</span>
|
||||||
|
<span class="detail__value">{{ selectedConnectionProfile().endpointLabel }}:{{ selectedConnectionProfile().portLabel }}</span>
|
||||||
|
|
||||||
|
<span class="detail__label">Probe</span>
|
||||||
|
<span class="detail__value">{{ probeType(selectedHost()!) }}</span>
|
||||||
|
|
||||||
|
<span class="detail__label">Last seen</span>
|
||||||
|
<span class="detail__value">{{ lastSeenLabel(selectedHost()!) }}</span>
|
||||||
|
|
||||||
|
<span class="detail__label">Mapped targets</span>
|
||||||
|
<span class="detail__value">{{ selectedHostTargets().length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="detail__summary">{{ selectedConnectionProfile().summary }}</p>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ hostId: selectedHost()!.hostId }" queryParamsHandling="merge">Open Targets</a>
|
<a [routerLink]="hostDetailLink(selectedHost()!.hostId)">Open Host Detail</a>
|
||||||
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ agentId: selectedHost()!.agentId }" queryParamsHandling="merge">Open Agent</a>
|
<a [routerLink]="['/environments/targets']" [queryParams]="{ hostId: selectedHost()!.hostId }">Open Targets</a>
|
||||||
<a [routerLink]="['/ops/integrations']" queryParamsHandling="merge">Integrations</a>
|
<a [routerLink]="['/ops/operations/agents']" [queryParams]="{ agentId: selectedHost()!.agentId }">Open Agent</a>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg class="empty-state__icon" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>
|
<span>Select a host row to inspect runtime coverage and mapped targets.</span>
|
||||||
<span>Select a host row to inspect runtime drift and impact.</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</article>
|
</article>
|
||||||
@@ -168,7 +263,6 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
padding: 0.1rem 0.45rem;
|
padding: 0.1rem 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Filters --- */
|
|
||||||
.filters {
|
.filters {
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -180,8 +274,15 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters__item { display: grid; gap: 0.15rem; }
|
.filters__item {
|
||||||
.filters__item--wide { flex: 1; min-width: 180px; }
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters__item--wide {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.filters label {
|
.filters label {
|
||||||
font-size: 0.67rem;
|
font-size: 0.67rem;
|
||||||
@@ -209,7 +310,6 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
box-shadow: 0 0 0 2px var(--color-focus-ring);
|
box-shadow: 0 0 0 2px var(--color-focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Banner --- */
|
|
||||||
.banner {
|
.banner {
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -225,7 +325,6 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
border-color: var(--color-status-error-border);
|
border-color: var(--color-status-error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Skeleton --- */
|
|
||||||
.skeleton-table {
|
.skeleton-table {
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -235,30 +334,43 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-row { display: flex; gap: 0.75rem; }
|
.skeleton-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.skeleton-line {
|
.skeleton-line {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 0.65rem;
|
height: 0.65rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: linear-gradient(90deg, var(--color-skeleton-base) 25%, var(--color-skeleton-highlight) 50%, var(--color-skeleton-base) 75%);
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-skeleton-base) 25%,
|
||||||
|
var(--color-skeleton-highlight) 50%,
|
||||||
|
var(--color-skeleton-base) 75%
|
||||||
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s ease-in-out infinite;
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-line--title { height: 0.85rem; max-width: 30%; }
|
.skeleton-line--title {
|
||||||
.skeleton-line--short { max-width: 40%; }
|
height: 0.85rem;
|
||||||
|
max-width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--short {
|
||||||
|
max-width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Layout --- */
|
|
||||||
.split {
|
.split {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
grid-template-columns: 1.45fr 1fr;
|
grid-template-columns: 1.55fr 1fr;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,12 +380,9 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
padding: 0.7rem;
|
padding: 0.7rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.5rem;
|
||||||
transition: box-shadow 180ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover { box-shadow: var(--shadow-sm); }
|
|
||||||
|
|
||||||
.card h2 {
|
.card h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
@@ -281,18 +390,30 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Table --- */
|
.card__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__header p {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
max-height: 420px;
|
max-height: 520px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table styling provided by global .stella-table class */
|
|
||||||
th {
|
th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr {
|
tbody tr {
|
||||||
@@ -300,41 +421,77 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
transition: background 120ms ease;
|
transition: background 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:nth-child(even) { background: var(--color-surface-primary); }
|
tbody tr:hover {
|
||||||
tbody tr:hover { background: var(--color-brand-soft); }
|
background: var(--color-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
tbody tr.active {
|
tbody tr.active {
|
||||||
background: var(--color-brand-primary-10);
|
background: var(--color-brand-primary-10);
|
||||||
box-shadow: inset 3px 0 0 var(--color-brand-primary);
|
box-shadow: inset 3px 0 0 var(--color-brand-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-name { font-weight: 500; }
|
.cell-host__main {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Status badges --- */
|
.cell-host__link {
|
||||||
.status-badge {
|
color: var(--color-text-link);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-host__link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-host__meta {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-cell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: var(--radius-full);
|
gap: 0.35rem;
|
||||||
font-size: 0.67rem;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge--healthy { background: var(--color-status-success-bg); color: var(--color-status-success-text); border-color: var(--color-status-success-border); }
|
.probe-cell__type {
|
||||||
.status-badge--degraded { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); border-color: var(--color-status-warning-border); }
|
color: var(--color-text-muted);
|
||||||
.status-badge--unhealthy { background: var(--color-status-error-bg); color: var(--color-status-error-text); border-color: var(--color-status-error-border); }
|
font-size: 0.68rem;
|
||||||
.status-badge--muted { background: var(--color-surface-tertiary); color: var(--color-text-muted); border-color: var(--color-border-primary); }
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Detail panel --- */
|
.detail {
|
||||||
.detail__name { margin: 0; font-size: 0.82rem; color: var(--color-text-primary); }
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__meta {
|
||||||
|
margin: 0.12rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
.detail__grid {
|
.detail__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 0.2rem 0.6rem;
|
gap: 0.25rem 0.6rem;
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,12 +504,22 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
padding-top: 0.1rem;
|
padding-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail__value { color: var(--color-text-secondary); }
|
.detail__value {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.detail p { margin: 0; color: var(--color-text-secondary); font-size: 0.75rem; }
|
.detail__summary {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Actions --- */
|
.actions {
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.actions a {
|
.actions a {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -372,39 +539,28 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
color: var(--color-text-link-hover);
|
color: var(--color-text-link-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Empty states --- */
|
.empty-cell,
|
||||||
.empty-cell {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
padding: 1rem 0.5rem;
|
padding: 1rem 0.5rem;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-cell__icon { opacity: 0.4; flex-shrink: 0; }
|
@media (max-width: 1080px) {
|
||||||
|
.split {
|
||||||
.empty-state {
|
grid-template-columns: 1fr;
|
||||||
display: flex;
|
}
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 1rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.76rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state__icon { opacity: 0.35; }
|
|
||||||
|
|
||||||
.muted { color: var(--color-text-secondary); font-size: 0.74rem; }
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.filters { flex-direction: column; }
|
.filters {
|
||||||
.filters__item--wide { min-width: auto; }
|
flex-direction: column;
|
||||||
.split { grid-template-columns: 1fr; }
|
}
|
||||||
|
|
||||||
|
.filters__item--wide {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`],
|
`],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -412,45 +568,68 @@ import { TopologyHost, TopologyTarget } from './topology.models';
|
|||||||
export class TopologyHostsPageComponent {
|
export class TopologyHostsPageComponent {
|
||||||
private readonly topologyApi = inject(TopologyDataService);
|
private readonly topologyApi = inject(TopologyDataService);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly helperCtx = inject(StellaHelperContextService);
|
||||||
|
|
||||||
readonly context = inject(PlatformContextStore);
|
readonly context = inject(PlatformContextStore);
|
||||||
|
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly searchQuery = signal('');
|
readonly searchQuery = signal('');
|
||||||
readonly runtimeFilter = signal('all');
|
readonly runtimeFilter = signal('all');
|
||||||
readonly statusFilter = signal('all');
|
readonly statusFilter = signal<HostStatusFilter>('all');
|
||||||
|
readonly probeFilter = signal<ProbeFilter>('all');
|
||||||
readonly selectedHostId = signal('');
|
readonly selectedHostId = signal('');
|
||||||
|
|
||||||
readonly hosts = signal<TopologyHost[]>([]);
|
readonly hosts = signal<TopologyHost[]>([]);
|
||||||
readonly targets = signal<TopologyTarget[]>([]);
|
readonly targets = signal<TopologyTarget[]>([]);
|
||||||
|
|
||||||
readonly runtimeOptions = computed(() =>
|
readonly runtimeOptions = computed(() =>
|
||||||
[...new Set(this.hosts().map((item) => item.runtimeType))].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })),
|
[...new Set(this.hosts().map((item) => item.runtimeType))]
|
||||||
|
.filter((value) => value.trim().length > 0)
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })),
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly filteredHosts = computed(() => {
|
readonly filteredHosts = computed(() => {
|
||||||
const query = this.searchQuery().trim().toLowerCase();
|
const query = this.searchQuery().trim().toLowerCase();
|
||||||
const runtime = this.runtimeFilter();
|
const runtime = this.runtimeFilter();
|
||||||
const status = this.statusFilter();
|
const status = this.statusFilter();
|
||||||
|
const probe = this.probeFilter();
|
||||||
|
|
||||||
return this.hosts().filter((item) => {
|
return this.hosts().filter((item) => {
|
||||||
const matchesQuery =
|
const matchesQuery =
|
||||||
!query ||
|
!query ||
|
||||||
[item.hostName, item.hostId, item.regionId, item.environmentId, item.runtimeType]
|
[
|
||||||
.some((value) => value.toLowerCase().includes(query));
|
item.hostName,
|
||||||
|
item.hostId,
|
||||||
|
item.regionId,
|
||||||
|
item.environmentId,
|
||||||
|
item.runtimeType,
|
||||||
|
item.agentId,
|
||||||
|
probeStatusLabel(normalizeProbeStatus(item.probeStatus)),
|
||||||
|
].some((value) => value.toLowerCase().includes(query));
|
||||||
const matchesRuntime = runtime === 'all' || item.runtimeType === runtime;
|
const matchesRuntime = runtime === 'all' || item.runtimeType === runtime;
|
||||||
const normalizedStatus = item.status.trim().toLowerCase();
|
const normalizedStatus = item.status.trim().toLowerCase();
|
||||||
const matchesStatus = status === 'all' || normalizedStatus === status;
|
const matchesStatus = status === 'all' || normalizedStatus === status;
|
||||||
return matchesQuery && matchesRuntime && matchesStatus;
|
const monitored = hasRuntimeProbe(item);
|
||||||
|
const matchesProbe =
|
||||||
|
probe === 'all' ||
|
||||||
|
(probe === 'monitored' && monitored) ||
|
||||||
|
(probe === 'unmonitored' && !monitored);
|
||||||
|
|
||||||
|
return matchesQuery && matchesRuntime && matchesStatus && matchesProbe;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedHost = computed(() => {
|
readonly selectedHost = computed(() => {
|
||||||
const selectedId = this.selectedHostId();
|
const selectedId = this.selectedHostId();
|
||||||
|
const filteredHosts = this.filteredHosts();
|
||||||
|
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
return this.filteredHosts()[0] ?? null;
|
return filteredHosts[0] ?? null;
|
||||||
}
|
}
|
||||||
return this.hosts().find((item) => item.hostId === selectedId) ?? null;
|
|
||||||
|
return filteredHosts.find((item) => item.hostId === selectedId) ?? filteredHosts[0] ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedHostTargets = computed(() => {
|
readonly selectedHostTargets = computed(() => {
|
||||||
@@ -458,7 +637,34 @@ export class TopologyHostsPageComponent {
|
|||||||
if (!host) {
|
if (!host) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.targets().filter((item) => item.hostId === host.hostId);
|
|
||||||
|
return this.targets()
|
||||||
|
.filter((item) => item.hostId === host.hostId)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly selectedConnectionProfile = computed(() => {
|
||||||
|
const host = this.selectedHost();
|
||||||
|
if (!host) {
|
||||||
|
return inferConnectionProfile('-', 'docker_host');
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferConnectionProfile(host.hostName, host.runtimeType);
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly monitoredHostsCount = computed(() => this.hosts().filter((host) => hasRuntimeProbe(host)).length);
|
||||||
|
readonly helperContexts = computed(() => {
|
||||||
|
const contexts: string[] = [];
|
||||||
|
if (!this.loading() && this.filteredHosts().length === 0) {
|
||||||
|
contexts.push('empty-table');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.hosts().some((host) => host.status.trim().toLowerCase() === 'unknown') ||
|
||||||
|
(!this.loading() && this.hosts().length > 0 && this.monitoredHostsCount() === 0)
|
||||||
|
) {
|
||||||
|
contexts.push('health-unknown');
|
||||||
|
}
|
||||||
|
return contexts;
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -469,6 +675,7 @@ export class TopologyHostsPageComponent {
|
|||||||
if (hostId) {
|
if (hostId) {
|
||||||
this.selectedHostId.set(hostId);
|
this.selectedHostId.set(hostId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment = params.get('environment');
|
const environment = params.get('environment');
|
||||||
if (environment) {
|
if (environment) {
|
||||||
this.searchQuery.set(environment);
|
this.searchQuery.set(environment);
|
||||||
@@ -479,6 +686,71 @@ export class TopologyHostsPageComponent {
|
|||||||
this.context.contextVersion();
|
this.context.contextVersion();
|
||||||
this.load();
|
this.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
this.helperCtx.setScope('topology-hosts', this.helperContexts());
|
||||||
|
}, { allowSignalWrites: true });
|
||||||
|
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('topology-hosts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
hostDetailLink(hostId: string): string[] {
|
||||||
|
return ['/environments/hosts', hostId];
|
||||||
|
}
|
||||||
|
|
||||||
|
hostStatusTone(status: string): 'success' | 'warning' | 'error' | 'neutral' {
|
||||||
|
const normalized = status.trim().toLowerCase();
|
||||||
|
if (normalized === 'healthy') {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
if (normalized === 'degraded') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
if (normalized === 'offline' || normalized === 'unhealthy') {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
probeTone(host: TopologyHost): 'success' | 'error' | 'neutral' {
|
||||||
|
return probeStatusTone(normalizeProbeStatus(host.probeStatus));
|
||||||
|
}
|
||||||
|
|
||||||
|
probeLabel(host: TopologyHost): string {
|
||||||
|
return probeStatusLabel(normalizeProbeStatus(host.probeStatus));
|
||||||
|
}
|
||||||
|
|
||||||
|
probeType(host: TopologyHost): string {
|
||||||
|
const normalizedStatus = normalizeProbeStatus(host.probeStatus);
|
||||||
|
if (normalizedStatus !== 'active') {
|
||||||
|
return probeTypeLabel(host.probeType ?? inferConnectionProfile(host.hostName, host.runtimeType).probeRecommendation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return probeTypeLabel(host.probeType ?? inferConnectionProfile(host.hostName, host.runtimeType).probeRecommendation);
|
||||||
|
}
|
||||||
|
|
||||||
|
showProbeType(host: TopologyHost): boolean {
|
||||||
|
return normalizeProbeStatus(host.probeStatus) === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
probeTooltip(host: TopologyHost): string {
|
||||||
|
const lastSeen = host.probeLastHeartbeat ?? host.lastSeenAt;
|
||||||
|
const status = this.probeLabel(host);
|
||||||
|
const type = this.probeType(host);
|
||||||
|
|
||||||
|
if (!lastSeen) {
|
||||||
|
return `${status}${type ? ` - ${type}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${status}${type ? ` - ${type}` : ''}. Last heartbeat ${this.relativeTime(lastSeen)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSeenLabel(host: TopologyHost): string {
|
||||||
|
const lastSeen = host.probeLastHeartbeat ?? host.lastSeenAt;
|
||||||
|
return lastSeen ? this.relativeTime(lastSeen) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
private relativeTime(value: string): string {
|
||||||
|
return new RelativeTimePipe().transform(value) || value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private load(): void {
|
private load(): void {
|
||||||
@@ -494,9 +766,11 @@ export class TopologyHostsPageComponent {
|
|||||||
next: ({ hosts, targets }) => {
|
next: ({ hosts, targets }) => {
|
||||||
this.hosts.set(hosts);
|
this.hosts.set(hosts);
|
||||||
this.targets.set(targets);
|
this.targets.set(targets);
|
||||||
|
|
||||||
if (!this.selectedHostId() && hosts.length > 0) {
|
if (!this.selectedHostId() && hosts.length > 0) {
|
||||||
this.selectedHostId.set(hosts[0].hostId);
|
this.selectedHostId.set(hosts[0].hostId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
error: (err: unknown) => {
|
error: (err: unknown) => {
|
||||||
@@ -508,5 +782,3 @@ export class TopologyHostsPageComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ export interface PlatformListResponse<T> {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TopologyProbeStatus = 'active' | 'offline' | 'not_installed';
|
||||||
|
export type TopologyProbeType = 'ebpf' | 'etw' | 'dyld';
|
||||||
|
|
||||||
export interface TopologyRegion {
|
export interface TopologyRegion {
|
||||||
regionId: string;
|
regionId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -57,6 +60,9 @@ export interface TopologyHost {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
targetCount: number;
|
targetCount: number;
|
||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
|
probeStatus?: TopologyProbeStatus | null;
|
||||||
|
probeType?: TopologyProbeType | null;
|
||||||
|
probeLastHeartbeat?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopologyAgent {
|
export interface TopologyAgent {
|
||||||
@@ -128,4 +134,3 @@ export interface ReadinessReport {
|
|||||||
evaluatedAt: string;
|
evaluatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user