mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Add fleet governance projection
This commit is contained in:
parent
97bf4af36d
commit
9bd67fe2c1
21 changed files with 1259 additions and 103 deletions
|
|
@ -4039,6 +4039,112 @@
|
|||
"kind": "file"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "L22",
|
||||
"name": "Fleet governance and rollout control",
|
||||
"target_score": 6,
|
||||
"current_score": 6,
|
||||
"status": "partial",
|
||||
"completion": {
|
||||
"state": "bounded-residual",
|
||||
"summary": "Fleet governance and rollout control now has a first-class governed floor: /api/connections carries enrollment, liveness, version drift, adapter health, config rollout, credential status, update posture, and remote-control posture as a canonical fleet projection, and Infrastructure systems surfaces those facts as a central fleet-governance strip plus row-level attention signals without paid-surface or monitor-count gating. Deeper desired-vs-applied config drift, staged rollout operations, richer credential rotation state, and command-policy enforcement remain a named post-RC hardening track.",
|
||||
"tracking": [
|
||||
{
|
||||
"kind": "lane-followup",
|
||||
"id": "fleet-governance-rollout-control-post-rc-hardening"
|
||||
}
|
||||
]
|
||||
},
|
||||
"blockers": [],
|
||||
"subsystems": [],
|
||||
"evidence": [
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/subsystems/agent-lifecycle.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/subsystems/api-contracts.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/subsystems/registry.json",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/subsystems/storage-recovery.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/V6_BRIDGE_RELEASE_FOUNDATION_SPEC.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/api/__tests__/connections.test.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/api/connections.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Settings/connectionsTableModel.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Settings/useConnectionsLedger.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/connections_aggregator.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/connections_aggregator_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/connections_types.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/contract_test.go",
|
||||
"kind": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"release_gates": [
|
||||
|
|
@ -4746,6 +4852,17 @@
|
|||
],
|
||||
"subsystem_ids": []
|
||||
},
|
||||
{
|
||||
"id": "fleet-governance-rollout-control-post-rc-hardening",
|
||||
"summary": "Track broader fleet-governance rollout control beyond the current fleet projection floor, including desired-versus-applied config drift, staged config rollout state, richer credential rotation/expiry health, agent command-policy enforcement, and runtime proof across remote-control safety states.",
|
||||
"owner": "project-owner",
|
||||
"status": "planned",
|
||||
"recorded_at": "2026-04-25",
|
||||
"lane_ids": [
|
||||
"L22"
|
||||
],
|
||||
"subsystem_ids": []
|
||||
},
|
||||
{
|
||||
"id": "platform-admission-execution-post-rc-hardening",
|
||||
"summary": "Track broader platform-admission execution beyond the current first-lab-ready support-floor projection, including live vCenter proof before VMware can move to supported, recovery/control expansion, and future admitted-platform proof across setup, canonical projections, alerts, assistant read, and bounded control.",
|
||||
|
|
@ -4780,54 +4897,8 @@
|
|||
"subsystem_ids": []
|
||||
}
|
||||
],
|
||||
"coverage_gaps": [
|
||||
{
|
||||
"id": "fleet-governance-v1",
|
||||
"summary": "L16 covers install and registration continuity, but the governed map still underrepresents fleet governance primitives such as enrollment state, version drift, adapter health, config rollout, credential status, and remote control-plane safety.",
|
||||
"owner": "project-owner",
|
||||
"status": "planned",
|
||||
"recorded_at": "2026-03-17",
|
||||
"lane_ids": [
|
||||
"L16"
|
||||
],
|
||||
"subsystem_ids": [
|
||||
"agent-lifecycle"
|
||||
],
|
||||
"proposed_resolution": "lane-expansion",
|
||||
"coverage_impact": 10,
|
||||
"evidence": [
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/subsystems/agent-lifecycle.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/V6_BRIDGE_RELEASE_FOUNDATION_SPEC.md",
|
||||
"kind": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"candidate_lanes": [
|
||||
{
|
||||
"id": "fleet-governance-rollout-control",
|
||||
"name": "Fleet governance and rollout control",
|
||||
"summary": "Expand the current fleet lane from install and lifecycle continuity into governed enrollment, drift, adapter-health, config-rollout, and credential-status control surfaces.",
|
||||
"status": "planned",
|
||||
"recorded_at": "2026-03-17",
|
||||
"target_id": "v6-product-lane-expansion",
|
||||
"current_lane_ids": [
|
||||
"L16"
|
||||
],
|
||||
"coverage_gap_ids": [
|
||||
"fleet-governance-v1"
|
||||
],
|
||||
"subsystem_ids": [
|
||||
"agent-lifecycle"
|
||||
]
|
||||
}
|
||||
],
|
||||
"coverage_gaps": [],
|
||||
"candidate_lanes": [],
|
||||
"work_claims": [],
|
||||
"open_decisions": [],
|
||||
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
|
||||
|
|
|
|||
|
|
@ -269,6 +269,13 @@ the manifest's support-floor row.
|
|||
should only raise row-level attention when an attached or standalone agent
|
||||
has an update available, while the exact installed version belongs in the
|
||||
add/edit detail surfaces rather than as a permanent top-level table column.
|
||||
That compact fleet-control presentation is now backed by the canonical
|
||||
`/api/connections` `fleet` projection rather than page-local inference:
|
||||
`connectionsTableModel.ts`, `useConnectionsLedger.ts`, and
|
||||
`InfrastructureSourceManager.tsx` may format enrollment, liveness, version
|
||||
drift, adapter health, config rollout, credential status, update posture,
|
||||
and remote-control posture, but they must not reconstruct those facts from
|
||||
table copy, status badge labels, or provider-local error strings.
|
||||
Standalone host rows must still be recognizable at a glance, but that
|
||||
identity belongs in the existing row cells rather than new diagnostics
|
||||
columns: the landing table reuses the `System` and `Endpoint` cells for a
|
||||
|
|
|
|||
|
|
@ -1615,6 +1615,13 @@ the infrastructure ledger: `useConnectionsLedger.ts` must derive one canonical
|
|||
subtitle (`via platform API`, `via Pulse Agent`, or `via platform API and
|
||||
Pulse Agent`) from the shared system/component payload instead of letting page-
|
||||
local tables invent their own API-versus-agent badge heuristics.
|
||||
That same `/api/connections` row contract now also owns the fleet-governance
|
||||
projection consumed by the infrastructure workspace. `Connection.fleet` is the
|
||||
canonical machine-readable source for enrollment state, liveness, version
|
||||
drift, adapter health, config rollout, credential status, update posture, and
|
||||
remote-control posture; frontend settings surfaces may format those facts, but
|
||||
must not infer a second fleet state from row labels, error-message text, or
|
||||
provider-local table heuristics.
|
||||
That same shared infrastructure-settings boundary also owns install-profile
|
||||
semantics surfaced by
|
||||
`frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`:
|
||||
|
|
|
|||
|
|
@ -286,6 +286,10 @@ work extends shared components instead of creating new local variants.
|
|||
flows open as
|
||||
secondary interactions from that same destination instead of taking over the
|
||||
whole page.
|
||||
The same source-manager workspace may show a compact fleet-governance strip
|
||||
and row-level fleet attention badges, but those badges must be presentation
|
||||
of the canonical `/api/connections` `fleet` object rather than another
|
||||
frontend-owned lifecycle classifier.
|
||||
Those secondary views must stay under the same single `Infrastructure`
|
||||
sidebar destination, but they may open in governed modal/dialog chrome when
|
||||
that preserves the persistent source-manager page behind them.
|
||||
|
|
|
|||
|
|
@ -672,6 +672,7 @@
|
|||
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureSettingsModel.ts",
|
||||
"frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts",
|
||||
"frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx",
|
||||
|
|
@ -1001,6 +1002,7 @@
|
|||
"frontend-modern/src/components/Settings/discoverySettingsModel.ts",
|
||||
"frontend-modern/src/components/Settings/InfrastructureDiscoverySettingsDialog.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureSettingsModel.ts",
|
||||
"frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts",
|
||||
"frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx",
|
||||
|
|
|
|||
|
|
@ -288,11 +288,12 @@ querying, and the operator-facing storage health presentation layer.
|
|||
backend-authored cluster member nodes, adjacent storage/recovery surfaces
|
||||
may use that composition for explanatory UI only; they must not promote
|
||||
those child nodes into a second top-level grouped-system taxonomy or infer
|
||||
per-node storage ownership from the settings payload. The same shared `/api/connections` payload also owns any
|
||||
agent-version/update facts carried alongside those grouped rows; adjacent
|
||||
per-node storage ownership from the settings payload. The same shared
|
||||
`/api/connections` payload also owns any agent-version/update facts and
|
||||
fleet-governance posture carried alongside those grouped rows; adjacent
|
||||
storage or recovery surfaces may reuse that signal for operator context,
|
||||
but must not fork their own version-comparison semantics or another agent
|
||||
lifecycle vocabulary.
|
||||
but must not fork their own version-comparison semantics, credential-state
|
||||
classifier, or another agent lifecycle vocabulary.
|
||||
22. Keep backend-native platform actions on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app control for Assistant, storage and recovery may consume the refreshed recovery points afterward, but they must not grow a parallel recovery-local action transport or action-specific payload shape.
|
||||
23. Keep backend-native platform diagnostics on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app log reads for Assistant, storage and recovery may use those diagnostics during investigation, but they must not grow a parallel recovery-local log transport or diagnostic payload shape.
|
||||
24. Keep backend-native platform configuration reads on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app config for Assistant, storage and recovery may use that runtime shape during investigation, but they must not grow a parallel recovery-local config transport or provider-shaped configuration payload.
|
||||
|
|
|
|||
|
|
@ -104,6 +104,50 @@ describe('ConnectionsAPI', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('list() preserves fleet governance metadata on connection rows', async () => {
|
||||
const connections: Connection[] = [
|
||||
{
|
||||
id: 'agent:mini-pc',
|
||||
type: 'agent',
|
||||
name: 'mini-pc',
|
||||
address: 'mini-pc',
|
||||
state: 'active',
|
||||
stateReason: '',
|
||||
enabled: true,
|
||||
surfaces: ['host'],
|
||||
scope: { host: true },
|
||||
lastSeen: '2026-04-22T20:00:00Z',
|
||||
lastError: null,
|
||||
source: 'agent',
|
||||
fleet: {
|
||||
enrollmentState: 'enrolled',
|
||||
livenessState: 'active',
|
||||
versionDrift: 'behind',
|
||||
adapterHealth: 'healthy',
|
||||
configRollout: 'reported',
|
||||
credentialStatus: 'verified',
|
||||
updateStatus: 'update-available',
|
||||
remoteControl: 'enabled',
|
||||
},
|
||||
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
|
||||
},
|
||||
];
|
||||
mockedApiFetchJSON.mockResolvedValueOnce({ connections });
|
||||
|
||||
const result = await ConnectionsAPI.list();
|
||||
|
||||
expect(result.connections[0]?.fleet).toEqual({
|
||||
enrollmentState: 'enrolled',
|
||||
livenessState: 'active',
|
||||
versionDrift: 'behind',
|
||||
adapterHealth: 'healthy',
|
||||
configRollout: 'reported',
|
||||
credentialStatus: 'verified',
|
||||
updateStatus: 'update-available',
|
||||
remoteControl: 'enabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('list() preserves agent identity metadata on agent-backed connections', async () => {
|
||||
const connections: Connection[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,6 +21,24 @@ export type ConnectionState =
|
|||
|
||||
export type ConnectionSource = 'manual' | 'agent' | 'script';
|
||||
|
||||
export type ConnectionFleetEnrollmentState = 'configured' | 'enrolled' | 'paused' | 'pending';
|
||||
export type ConnectionFleetLivenessState = ConnectionState;
|
||||
export type ConnectionFleetVersionDrift = 'behind' | 'current' | 'unknown' | 'not-applicable';
|
||||
export type ConnectionFleetAdapterHealth =
|
||||
| 'blocked'
|
||||
| 'degraded'
|
||||
| 'healthy'
|
||||
| 'paused'
|
||||
| 'unknown';
|
||||
export type ConnectionFleetConfigRollout = 'configured' | 'paused' | 'reported' | 'unknown';
|
||||
export type ConnectionFleetCredentialStatus = 'invalid' | 'paused' | 'unknown' | 'verified';
|
||||
export type ConnectionFleetUpdateStatus =
|
||||
| 'current'
|
||||
| 'not-applicable'
|
||||
| 'unknown'
|
||||
| 'update-available';
|
||||
export type ConnectionFleetRemoteControl = 'disabled' | 'enabled' | 'not-applicable';
|
||||
|
||||
export interface ConnectionCapabilities {
|
||||
supportsPause: boolean;
|
||||
supportsScope: boolean;
|
||||
|
|
@ -32,6 +50,17 @@ export interface ConnectionError {
|
|||
at: string;
|
||||
}
|
||||
|
||||
export interface ConnectionFleetGovernance {
|
||||
enrollmentState: ConnectionFleetEnrollmentState;
|
||||
livenessState: ConnectionFleetLivenessState;
|
||||
versionDrift: ConnectionFleetVersionDrift;
|
||||
adapterHealth: ConnectionFleetAdapterHealth;
|
||||
configRollout: ConnectionFleetConfigRollout;
|
||||
credentialStatus: ConnectionFleetCredentialStatus;
|
||||
updateStatus: ConnectionFleetUpdateStatus;
|
||||
remoteControl: ConnectionFleetRemoteControl;
|
||||
}
|
||||
|
||||
export interface ConnectionAgentIdentity {
|
||||
hostname?: string;
|
||||
platform?: string;
|
||||
|
|
@ -61,6 +90,7 @@ export interface Connection {
|
|||
agentVersion?: string;
|
||||
expectedAgentVersion?: string;
|
||||
agentUpdateAvailable?: boolean;
|
||||
fleet?: ConnectionFleetGovernance;
|
||||
capabilities: ConnectionCapabilities;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ import {
|
|||
} from '@/components/shared/Table';
|
||||
import {
|
||||
connectionAgentVersionPresentation,
|
||||
fleetSignalClassName,
|
||||
infrastructureSourcePresentation,
|
||||
surfaceLabel,
|
||||
type FleetGovernanceSignal,
|
||||
type InfrastructureSystemRow,
|
||||
} from './connectionsTableModel';
|
||||
import type { DiscoveredServer, DiscoveryScanStatus } from './infrastructureSettingsModel';
|
||||
|
|
@ -160,6 +162,16 @@ const rowHasAgentCoverage = (row: InfrastructureSystemRow): boolean =>
|
|||
(member) => member.source === 'agent' || member.source === 'both' || member.agentConnection,
|
||||
);
|
||||
|
||||
const rowFleetSignals = (row: InfrastructureSystemRow): FleetGovernanceSignal[] => [
|
||||
...row.fleetSignals,
|
||||
...row.members.flatMap((member) => member.fleetSignals),
|
||||
];
|
||||
|
||||
const rowHasFleetTone = (
|
||||
row: InfrastructureSystemRow,
|
||||
predicate: (signal: FleetGovernanceSignal) => boolean,
|
||||
): boolean => rowFleetSignals(row).some(predicate);
|
||||
|
||||
export const InfrastructureSourceManager: Component<InfrastructureSourceManagerProps> = (props) => {
|
||||
let layoutContainerRef: HTMLDivElement | undefined;
|
||||
const products = createMemo(() => getInfrastructureSourceManagerProducts());
|
||||
|
|
@ -231,6 +243,44 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
|
|||
const apiOnlySystemCount = createMemo(
|
||||
() => props.rows().filter((row) => rowHasApiCoverage(row) && !rowHasAgentCoverage(row)).length,
|
||||
);
|
||||
const liveFleetSystemCount = createMemo(
|
||||
() =>
|
||||
props
|
||||
.rows()
|
||||
.filter((row) =>
|
||||
rowHasFleetTone(row, (signal) => signal.key === 'liveness' && signal.tone === 'ok'),
|
||||
).length,
|
||||
);
|
||||
const fleetAttentionSystemCount = createMemo(
|
||||
() =>
|
||||
props
|
||||
.rows()
|
||||
.filter((row) =>
|
||||
rowHasFleetTone(row, (signal) => signal.tone === 'warning' || signal.tone === 'critical'),
|
||||
).length,
|
||||
);
|
||||
const credentialIssueSystemCount = createMemo(
|
||||
() =>
|
||||
props
|
||||
.rows()
|
||||
.filter((row) =>
|
||||
rowHasFleetTone(
|
||||
row,
|
||||
(signal) => signal.key === 'credentials' && signal.tone === 'critical',
|
||||
),
|
||||
).length,
|
||||
);
|
||||
const remoteControlEnabledSystemCount = createMemo(
|
||||
() =>
|
||||
props
|
||||
.rows()
|
||||
.filter((row) =>
|
||||
rowHasFleetTone(
|
||||
row,
|
||||
(signal) => signal.key === 'remote-control' && signal.tone === 'info',
|
||||
),
|
||||
).length,
|
||||
);
|
||||
const discoveryReadinessLabel = createMemo(() => {
|
||||
if (props.discoveryScanStatus().scanning) return 'Scanning now';
|
||||
if (discoveredCandidateCount() > 0) return `${discoveredCandidateCount()} to review`;
|
||||
|
|
@ -461,6 +511,57 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
|
|||
</dl>
|
||||
|
||||
<p class="mt-3 text-xs leading-5 text-muted">{setupConfidenceAction().detail}</p>
|
||||
|
||||
<div class="mt-5 border-t border-border-subtle pt-4">
|
||||
<div class="max-w-3xl">
|
||||
<h3 class="text-sm font-semibold text-base-content">Fleet governance</h3>
|
||||
<p class="mt-1 text-sm leading-5 text-muted">
|
||||
Enrollment, liveness, version drift, adapter health, config state, credentials, update
|
||||
status, and remote-control posture come from the same governed connections ledger.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 grid gap-0 border-y border-border-subtle sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div class="border-b border-border-subtle px-3 py-3 sm:border-r xl:border-b-0">
|
||||
<dt class="text-[11px] font-medium uppercase tracking-[0.08em] text-muted">
|
||||
Managed fleet
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-base-content">
|
||||
{formatCount(connectedSystemCount(), 'system')}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="border-b border-border-subtle px-3 py-3 xl:border-b-0 xl:border-r">
|
||||
<dt class="text-[11px] font-medium uppercase tracking-[0.08em] text-muted">Live</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-base-content">
|
||||
{formatCount(liveFleetSystemCount(), 'system')}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="border-b border-border-subtle px-3 py-3 sm:border-b-0 sm:border-r">
|
||||
<dt class="text-[11px] font-medium uppercase tracking-[0.08em] text-muted">
|
||||
Needs attention
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-base-content">
|
||||
{formatCount(fleetAttentionSystemCount(), 'system')}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="border-b border-border-subtle px-3 py-3 sm:border-b-0 xl:border-r">
|
||||
<dt class="text-[11px] font-medium uppercase tracking-[0.08em] text-muted">
|
||||
Credential issues
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-base-content">
|
||||
{formatCount(credentialIssueSystemCount(), 'system')}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="px-3 py-3">
|
||||
<dt class="text-[11px] font-medium uppercase tracking-[0.08em] text-muted">
|
||||
Remote control
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-base-content">
|
||||
{formatCount(remoteControlEnabledSystemCount(), 'system')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
|
|
@ -651,6 +752,18 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
|
|||
{row.lastActivityText}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1">
|
||||
<For each={row.fleetHighlights}>
|
||||
{(signal) => (
|
||||
<span
|
||||
class={fleetSignalClassName(signal.tone)}
|
||||
title={signal.detail}
|
||||
>
|
||||
{signal.label}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<Show when={actionColumnVisible()}>
|
||||
|
|
@ -765,6 +878,18 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
|
|||
{member.lastActivityText}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1">
|
||||
<For each={member.fleetHighlights}>
|
||||
{(signal) => (
|
||||
<span
|
||||
class={fleetSignalClassName(signal.tone)}
|
||||
title={signal.detail}
|
||||
>
|
||||
{signal.label}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<Show when={actionColumnVisible()}>
|
||||
|
|
@ -1006,6 +1131,16 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
|
|||
<span class="text-[12px] text-muted/90">
|
||||
{member.lastActivityText}
|
||||
</span>
|
||||
<For each={member.fleetHighlights}>
|
||||
{(signal) => (
|
||||
<span
|
||||
class={fleetSignalClassName(signal.tone)}
|
||||
title={signal.detail}
|
||||
>
|
||||
{signal.label}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1032,6 +1167,16 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
|
|||
<span class="text-[12px] text-muted/90">
|
||||
{row.lastActivityText}
|
||||
</span>
|
||||
<For each={row.fleetHighlights}>
|
||||
{(signal) => (
|
||||
<span
|
||||
class={fleetSignalClassName(signal.tone)}
|
||||
title={signal.detail}
|
||||
>
|
||||
{signal.label}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={!props.readOnly && rowInteractive(row)}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ const row = (overrides: Partial<InfrastructureSystemRow> = {}): InfrastructureSy
|
|||
agentUpdateCount: 0,
|
||||
lastActivityText: '5s ago',
|
||||
lastErrorMessage: undefined,
|
||||
fleetSignals: [],
|
||||
fleetHighlights: [],
|
||||
enabled: connection.enabled,
|
||||
canEdit: true,
|
||||
canPause: connection.capabilities.supportsPause,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { InfrastructureSourceManager } from '../InfrastructureSourceManager';
|
||||
import type { FleetGovernanceSignal, InfrastructureSystemRow } from '../connectionsTableModel';
|
||||
import type { Connection } from '@/api/connections';
|
||||
|
||||
const connectionFixture = (overrides: Partial<Connection> = {}): Connection => ({
|
||||
id: 'agent:host-1',
|
||||
type: 'agent',
|
||||
name: 'host-1',
|
||||
address: 'host-1',
|
||||
state: 'active',
|
||||
stateReason: '',
|
||||
enabled: true,
|
||||
surfaces: ['host'],
|
||||
scope: { host: true },
|
||||
lastSeen: '2026-04-23T12:00:00Z',
|
||||
lastError: null,
|
||||
source: 'agent',
|
||||
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const signal = (overrides: Partial<FleetGovernanceSignal>): FleetGovernanceSignal => ({
|
||||
key: 'liveness',
|
||||
label: 'Fleet OK',
|
||||
detail: 'No fleet warnings.',
|
||||
tone: 'ok',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const row = (overrides: Partial<InfrastructureSystemRow> = {}): InfrastructureSystemRow => {
|
||||
const connection = overrides.connection ?? connectionFixture();
|
||||
const fleetSignals = overrides.fleetSignals ?? [
|
||||
signal({ key: 'liveness', label: 'Live', tone: 'ok' }),
|
||||
];
|
||||
return {
|
||||
id: connection.id,
|
||||
ownerType: connection.type,
|
||||
name: connection.name,
|
||||
subtitle: 'via Pulse Agent',
|
||||
source: 'agent',
|
||||
host: connection.address,
|
||||
coverageLabels: ['Host telemetry'],
|
||||
statusLabel: 'Active',
|
||||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '5s ago',
|
||||
fleetSignals,
|
||||
fleetHighlights: overrides.fleetHighlights ?? [signal({})],
|
||||
enabled: connection.enabled,
|
||||
canEdit: false,
|
||||
canPause: false,
|
||||
canRemove: true,
|
||||
isAgent: connection.type === 'agent',
|
||||
isCluster: false,
|
||||
attachedConnections: [],
|
||||
members: [],
|
||||
connection,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
describe('InfrastructureSourceManager fleet governance', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('surfaces governed fleet counts and row-level attention signals', () => {
|
||||
render(() => (
|
||||
<InfrastructureSourceManager
|
||||
rows={() => [
|
||||
row({
|
||||
fleetSignals: [
|
||||
signal({ key: 'liveness', label: 'Live', tone: 'ok' }),
|
||||
signal({ key: 'remote-control', label: 'Remote control enabled', tone: 'info' }),
|
||||
],
|
||||
fleetHighlights: [
|
||||
signal({ key: 'remote-control', label: 'Remote control enabled', tone: 'info' }),
|
||||
],
|
||||
}),
|
||||
row({
|
||||
id: 'pve:lab',
|
||||
ownerType: 'pve',
|
||||
name: 'lab',
|
||||
source: 'api',
|
||||
connection: connectionFixture({
|
||||
id: 'pve:lab',
|
||||
type: 'pve',
|
||||
name: 'lab',
|
||||
source: 'manual',
|
||||
capabilities: { supportsPause: true, supportsScope: true, supportsTest: true },
|
||||
}),
|
||||
fleetSignals: [
|
||||
signal({ key: 'liveness', label: 'Unauthorized', tone: 'critical' }),
|
||||
signal({ key: 'credentials', label: 'Credentials invalid', tone: 'critical' }),
|
||||
],
|
||||
fleetHighlights: [
|
||||
signal({ key: 'credentials', label: 'Credentials invalid', tone: 'critical' }),
|
||||
],
|
||||
}),
|
||||
]}
|
||||
discoveredNodes={() => []}
|
||||
discoveryEnabled
|
||||
discoveryScanStatus={() => ({ scanning: false })}
|
||||
readOnly
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Fleet governance')).toBeInTheDocument();
|
||||
expect(screen.getByText('Managed fleet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs attention')).toBeInTheDocument();
|
||||
expect(screen.getByText('Credential issues')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remote control')).toBeInTheDocument();
|
||||
expect(screen.getByText('Credentials invalid')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remote control enabled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Connection } from '@/api/connections';
|
||||
import type { InfrastructureSystemRow } from '../connectionsTableModel';
|
||||
import type {
|
||||
InfrastructureSystemMemberRow,
|
||||
InfrastructureSystemRow,
|
||||
} from '../connectionsTableModel';
|
||||
import { InfrastructureWorkspace } from '../InfrastructureWorkspace';
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
|
|
@ -12,6 +15,20 @@ const connectionState = vi.hoisted(() => ({
|
|||
connections: [] as Connection[],
|
||||
rows: null as InfrastructureSystemRow[] | null,
|
||||
}));
|
||||
const emptyFleetRow = vi.hoisted(
|
||||
() =>
|
||||
({
|
||||
fleetSignals: [],
|
||||
fleetHighlights: [],
|
||||
}) as Pick<InfrastructureSystemRow, 'fleetSignals' | 'fleetHighlights'>,
|
||||
);
|
||||
const emptyFleetMember = vi.hoisted(
|
||||
() =>
|
||||
({
|
||||
fleetSignals: [],
|
||||
fleetHighlights: [],
|
||||
}) as Pick<InfrastructureSystemMemberRow, 'fleetSignals' | 'fleetHighlights'>,
|
||||
);
|
||||
const navigateSpy = vi.hoisted(() => vi.fn());
|
||||
const presentationPolicyIsReadOnlyMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const onboardingMetricsTrackers = vi.hoisted(
|
||||
|
|
@ -119,6 +136,7 @@ vi.mock('../useConnectionsLedger', () => ({
|
|||
agentUpdateCount: 0,
|
||||
lastActivityText: '1m ago',
|
||||
lastErrorMessage: connection.lastError?.message,
|
||||
...emptyFleetRow,
|
||||
enabled: connection.enabled,
|
||||
canEdit: ['pve', 'pbs', 'pmg', 'vmware', 'truenas'].includes(connection.type),
|
||||
canPause: connection.capabilities.supportsPause,
|
||||
|
|
@ -304,8 +322,10 @@ describe('InfrastructureWorkspace', () => {
|
|||
expect(within(readiness).getByText('Agent coverage')).toBeInTheDocument();
|
||||
expect(within(readiness).getByText('Needs agent')).toBeInTheDocument();
|
||||
expect(within(readiness).getByText('Discovery')).toBeInTheDocument();
|
||||
expect(within(readiness).getAllByText('1 system')).toHaveLength(3);
|
||||
expect(within(readiness).getByText('0 systems')).toBeInTheDocument();
|
||||
expect(within(readiness).getByText('Fleet governance')).toBeInTheDocument();
|
||||
expect(within(readiness).getByText('Managed fleet')).toBeInTheDocument();
|
||||
expect(within(readiness).getAllByText('1 system')).toHaveLength(4);
|
||||
expect(within(readiness).getAllByText('0 systems')).toHaveLength(5);
|
||||
expect(within(readiness).getByText('Discovery off')).toBeInTheDocument();
|
||||
expect(within(readiness).getByRole('button', { name: /Install agents/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Proxmox VE')).toBeInTheDocument();
|
||||
|
|
@ -380,6 +400,7 @@ describe('InfrastructureWorkspace', () => {
|
|||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '1m ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: true,
|
||||
canPause: true,
|
||||
|
|
@ -402,6 +423,7 @@ describe('InfrastructureWorkspace', () => {
|
|||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '1m ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: false,
|
||||
canPause: false,
|
||||
|
|
@ -424,6 +446,7 @@ describe('InfrastructureWorkspace', () => {
|
|||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '1m ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: false,
|
||||
canPause: false,
|
||||
|
|
@ -543,6 +566,7 @@ describe('InfrastructureWorkspace', () => {
|
|||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '3s ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: true,
|
||||
canPause: true,
|
||||
|
|
@ -562,6 +586,7 @@ describe('InfrastructureWorkspace', () => {
|
|||
statusLabel: 'Active',
|
||||
statusClassName: 'bg-green-100 text-green-800',
|
||||
lastActivityText: '3s ago',
|
||||
...emptyFleetMember,
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
|
|
@ -753,6 +778,7 @@ describe('InfrastructureWorkspace', () => {
|
|||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '0s ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: false,
|
||||
canPause: false,
|
||||
|
|
@ -821,6 +847,7 @@ describe('InfrastructureWorkspace', () => {
|
|||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 1,
|
||||
lastActivityText: '1m ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: true,
|
||||
canPause: true,
|
||||
|
|
@ -888,6 +915,7 @@ describe('InfrastructureWorkspace', () => {
|
|||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '1m ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: true,
|
||||
canPause: true,
|
||||
|
|
@ -906,6 +934,7 @@ describe('InfrastructureWorkspace', () => {
|
|||
statusLabel: 'Active',
|
||||
statusClassName: 'bg-green-100 text-green-800',
|
||||
lastActivityText: '1m ago',
|
||||
...emptyFleetMember,
|
||||
primary: true,
|
||||
agentConnection: dellyAgent,
|
||||
},
|
||||
|
|
@ -919,6 +948,7 @@ describe('InfrastructureWorkspace', () => {
|
|||
statusLabel: 'Active',
|
||||
statusClassName: 'bg-green-100 text-green-800',
|
||||
lastActivityText: '1m ago',
|
||||
...emptyFleetMember,
|
||||
primary: false,
|
||||
agentConnection: minipcAgent,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { filterRepresentedDiscoveredServers } from '../infrastructureSettingsModel';
|
||||
import type { InfrastructureSystemRow } from '../connectionsTableModel';
|
||||
import type {
|
||||
InfrastructureSystemMemberRow,
|
||||
InfrastructureSystemRow,
|
||||
} from '../connectionsTableModel';
|
||||
|
||||
const emptyFleetRow = {
|
||||
fleetSignals: [],
|
||||
fleetHighlights: [],
|
||||
} satisfies Pick<InfrastructureSystemRow, 'fleetSignals' | 'fleetHighlights'>;
|
||||
|
||||
const emptyFleetMember = {
|
||||
fleetSignals: [],
|
||||
fleetHighlights: [],
|
||||
} satisfies Pick<InfrastructureSystemMemberRow, 'fleetSignals' | 'fleetHighlights'>;
|
||||
|
||||
describe('filterRepresentedDiscoveredServers', () => {
|
||||
it('removes a discovered platform candidate when the same platform member is already represented by host aliases', () => {
|
||||
|
|
@ -17,6 +30,7 @@ describe('filterRepresentedDiscoveredServers', () => {
|
|||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '4s ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: true,
|
||||
canPause: true,
|
||||
|
|
@ -36,6 +50,7 @@ describe('filterRepresentedDiscoveredServers', () => {
|
|||
statusLabel: 'Active',
|
||||
statusClassName: 'bg-green-100 text-green-800',
|
||||
lastActivityText: '4s ago',
|
||||
...emptyFleetMember,
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
|
|
@ -87,6 +102,7 @@ describe('filterRepresentedDiscoveredServers', () => {
|
|||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '4s ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: false,
|
||||
canPause: false,
|
||||
|
|
@ -145,6 +161,7 @@ describe('filterRepresentedDiscoveredServers', () => {
|
|||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '4s ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: true,
|
||||
canPause: true,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ describe('useConnectionsLedger', () => {
|
|||
lastSeen: '2026-04-23T12:00:00Z',
|
||||
lastError: null,
|
||||
source: 'agent',
|
||||
fleet: {
|
||||
enrollmentState: 'enrolled',
|
||||
livenessState: 'active',
|
||||
versionDrift: 'behind',
|
||||
adapterHealth: 'healthy',
|
||||
configRollout: 'reported',
|
||||
credentialStatus: 'verified',
|
||||
updateStatus: 'update-available',
|
||||
remoteControl: 'enabled',
|
||||
},
|
||||
agentIdentity: {
|
||||
hostname: 'tower',
|
||||
platform: 'unraid',
|
||||
|
|
@ -53,6 +63,11 @@ describe('useConnectionsLedger', () => {
|
|||
isCluster: false,
|
||||
coverageLabels: ['Host telemetry'],
|
||||
});
|
||||
expect(result.rows()[0].fleetHighlights.map((signal) => signal.label)).toEqual([
|
||||
'Version behind',
|
||||
'Update available',
|
||||
'Remote control enabled',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a Proxmox cluster row from the canonical system metadata', async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import type { Connection } from '@/api/connections';
|
||||
import type { ConnectionType } from '@/api/connections';
|
||||
import type {
|
||||
ConnectionFleetAdapterHealth,
|
||||
ConnectionFleetConfigRollout,
|
||||
ConnectionFleetCredentialStatus,
|
||||
ConnectionFleetEnrollmentState,
|
||||
ConnectionFleetGovernance,
|
||||
ConnectionFleetLivenessState,
|
||||
ConnectionFleetRemoteControl,
|
||||
ConnectionFleetUpdateStatus,
|
||||
ConnectionFleetVersionDrift,
|
||||
} from '@/api/connections';
|
||||
|
||||
export const lastActivityTextFromLastSeen = (lastSeen?: string | null): string => {
|
||||
if (!lastSeen) return 'No activity yet';
|
||||
|
|
@ -195,6 +206,373 @@ export const infrastructureSourcePresentation = (
|
|||
source: InfrastructureSourceKind,
|
||||
): InfrastructureSourcePresentation => SOURCE_PRESENTATION[source];
|
||||
|
||||
export type FleetGovernanceSignalKey =
|
||||
| 'enrollment'
|
||||
| 'liveness'
|
||||
| 'version'
|
||||
| 'adapter'
|
||||
| 'config'
|
||||
| 'credentials'
|
||||
| 'updates'
|
||||
| 'remote-control';
|
||||
|
||||
export type FleetGovernanceSignalTone = 'ok' | 'info' | 'warning' | 'critical' | 'muted';
|
||||
|
||||
export interface FleetGovernanceSignal {
|
||||
key: FleetGovernanceSignalKey;
|
||||
label: string;
|
||||
detail: string;
|
||||
tone: FleetGovernanceSignalTone;
|
||||
}
|
||||
|
||||
const DEFAULT_FLEET_GOVERNANCE: ConnectionFleetGovernance = {
|
||||
enrollmentState: 'pending',
|
||||
livenessState: 'pending',
|
||||
versionDrift: 'unknown',
|
||||
adapterHealth: 'unknown',
|
||||
configRollout: 'unknown',
|
||||
credentialStatus: 'unknown',
|
||||
updateStatus: 'unknown',
|
||||
remoteControl: 'not-applicable',
|
||||
};
|
||||
|
||||
export const fleetGovernanceForConnection = (connection: Connection): ConnectionFleetGovernance =>
|
||||
connection.fleet ?? DEFAULT_FLEET_GOVERNANCE;
|
||||
|
||||
const fleetSignalClassNameByTone: Record<FleetGovernanceSignalTone, string> = {
|
||||
ok: 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200',
|
||||
info: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-200',
|
||||
warning:
|
||||
'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200',
|
||||
critical:
|
||||
'border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-900 dark:bg-rose-950/30 dark:text-rose-200',
|
||||
muted: 'border-border bg-surface-alt text-muted',
|
||||
};
|
||||
|
||||
export const fleetSignalClassName = (tone: FleetGovernanceSignalTone): string =>
|
||||
`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${fleetSignalClassNameByTone[tone]}`;
|
||||
|
||||
const enrollmentSignal = (
|
||||
state: ConnectionFleetEnrollmentState,
|
||||
connection: Connection,
|
||||
): FleetGovernanceSignal => {
|
||||
switch (state) {
|
||||
case 'configured':
|
||||
return {
|
||||
key: 'enrollment',
|
||||
label: 'Configured',
|
||||
detail: 'This source is configured in the infrastructure ledger.',
|
||||
tone: 'ok',
|
||||
};
|
||||
case 'enrolled':
|
||||
return {
|
||||
key: 'enrollment',
|
||||
label: 'Enrolled',
|
||||
detail: 'This agent has reported into the fleet ledger.',
|
||||
tone: 'ok',
|
||||
};
|
||||
case 'paused':
|
||||
return {
|
||||
key: 'enrollment',
|
||||
label: 'Paused',
|
||||
detail: 'This source is present but currently paused.',
|
||||
tone: 'muted',
|
||||
};
|
||||
case 'pending':
|
||||
return {
|
||||
key: 'enrollment',
|
||||
label: 'Enrollment pending',
|
||||
detail:
|
||||
connection.type === 'agent'
|
||||
? 'Pulse has not received the first agent report yet.'
|
||||
: 'Pulse has not confirmed this source yet.',
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const livenessSignal = (state: ConnectionFleetLivenessState): FleetGovernanceSignal => {
|
||||
switch (state) {
|
||||
case 'active':
|
||||
return {
|
||||
key: 'liveness',
|
||||
label: 'Live',
|
||||
detail: 'Pulse has recent activity for this source.',
|
||||
tone: 'ok',
|
||||
};
|
||||
case 'paused':
|
||||
return {
|
||||
key: 'liveness',
|
||||
label: 'Paused',
|
||||
detail: 'Pulse is not polling this source while it is paused.',
|
||||
tone: 'muted',
|
||||
};
|
||||
case 'stale':
|
||||
return {
|
||||
key: 'liveness',
|
||||
label: 'Stale',
|
||||
detail: 'Pulse has not seen recent activity from this source.',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'pending':
|
||||
return {
|
||||
key: 'liveness',
|
||||
label: 'Pending',
|
||||
detail: 'Pulse is waiting for first activity from this source.',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'unauthorized':
|
||||
return {
|
||||
key: 'liveness',
|
||||
label: 'Unauthorized',
|
||||
detail: 'The configured credential is being rejected.',
|
||||
tone: 'critical',
|
||||
};
|
||||
case 'unreachable':
|
||||
return {
|
||||
key: 'liveness',
|
||||
label: 'Unreachable',
|
||||
detail: 'Pulse cannot currently reach this source.',
|
||||
tone: 'critical',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const versionSignal = (state: ConnectionFleetVersionDrift): FleetGovernanceSignal => {
|
||||
switch (state) {
|
||||
case 'behind':
|
||||
return {
|
||||
key: 'version',
|
||||
label: 'Version behind',
|
||||
detail: 'This agent is behind the current Pulse Agent target.',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'current':
|
||||
return {
|
||||
key: 'version',
|
||||
label: 'Version current',
|
||||
detail: 'This agent matches the current Pulse Agent target.',
|
||||
tone: 'ok',
|
||||
};
|
||||
case 'unknown':
|
||||
return {
|
||||
key: 'version',
|
||||
label: 'Version unknown',
|
||||
detail: 'Pulse does not yet have enough version data for this agent.',
|
||||
tone: 'muted',
|
||||
};
|
||||
case 'not-applicable':
|
||||
return {
|
||||
key: 'version',
|
||||
label: 'No agent version',
|
||||
detail: 'This source is not governed by the Pulse Agent binary version.',
|
||||
tone: 'muted',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const adapterSignal = (state: ConnectionFleetAdapterHealth): FleetGovernanceSignal => {
|
||||
switch (state) {
|
||||
case 'healthy':
|
||||
return {
|
||||
key: 'adapter',
|
||||
label: 'Adapter healthy',
|
||||
detail: 'The collection adapter is operating normally.',
|
||||
tone: 'ok',
|
||||
};
|
||||
case 'degraded':
|
||||
return {
|
||||
key: 'adapter',
|
||||
label: 'Adapter degraded',
|
||||
detail: 'The collection adapter needs attention or first confirmation.',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'blocked':
|
||||
return {
|
||||
key: 'adapter',
|
||||
label: 'Adapter blocked',
|
||||
detail: 'The collection adapter cannot complete its current read path.',
|
||||
tone: 'critical',
|
||||
};
|
||||
case 'paused':
|
||||
return {
|
||||
key: 'adapter',
|
||||
label: 'Adapter paused',
|
||||
detail: 'Collection is paused for this source.',
|
||||
tone: 'muted',
|
||||
};
|
||||
case 'unknown':
|
||||
return {
|
||||
key: 'adapter',
|
||||
label: 'Adapter unknown',
|
||||
detail: 'Pulse has not classified adapter health yet.',
|
||||
tone: 'muted',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const configSignal = (state: ConnectionFleetConfigRollout): FleetGovernanceSignal => {
|
||||
switch (state) {
|
||||
case 'configured':
|
||||
return {
|
||||
key: 'config',
|
||||
label: 'Config set',
|
||||
detail: 'This source has configured collection scope.',
|
||||
tone: 'ok',
|
||||
};
|
||||
case 'reported':
|
||||
return {
|
||||
key: 'config',
|
||||
label: 'Config reported',
|
||||
detail: 'This agent is reporting its applied runtime configuration.',
|
||||
tone: 'ok',
|
||||
};
|
||||
case 'paused':
|
||||
return {
|
||||
key: 'config',
|
||||
label: 'Config paused',
|
||||
detail: 'Config changes are not active while this source is paused.',
|
||||
tone: 'muted',
|
||||
};
|
||||
case 'unknown':
|
||||
return {
|
||||
key: 'config',
|
||||
label: 'Config unknown',
|
||||
detail: 'Pulse has not received enough runtime configuration state yet.',
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const credentialSignal = (state: ConnectionFleetCredentialStatus): FleetGovernanceSignal => {
|
||||
switch (state) {
|
||||
case 'verified':
|
||||
return {
|
||||
key: 'credentials',
|
||||
label: 'Credentials verified',
|
||||
detail: 'The current credential path is accepted.',
|
||||
tone: 'ok',
|
||||
};
|
||||
case 'invalid':
|
||||
return {
|
||||
key: 'credentials',
|
||||
label: 'Credentials invalid',
|
||||
detail: 'The current credential path is rejected by the source.',
|
||||
tone: 'critical',
|
||||
};
|
||||
case 'paused':
|
||||
return {
|
||||
key: 'credentials',
|
||||
label: 'Credentials paused',
|
||||
detail: 'Credential checks are paused with this source.',
|
||||
tone: 'muted',
|
||||
};
|
||||
case 'unknown':
|
||||
return {
|
||||
key: 'credentials',
|
||||
label: 'Credentials unknown',
|
||||
detail: 'Pulse has not verified this credential path yet.',
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updateSignal = (state: ConnectionFleetUpdateStatus): FleetGovernanceSignal => {
|
||||
switch (state) {
|
||||
case 'update-available':
|
||||
return {
|
||||
key: 'updates',
|
||||
label: 'Update available',
|
||||
detail: 'A newer Pulse Agent binary is available for this system.',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'current':
|
||||
return {
|
||||
key: 'updates',
|
||||
label: 'Update current',
|
||||
detail: 'This agent is already on the current target version.',
|
||||
tone: 'ok',
|
||||
};
|
||||
case 'unknown':
|
||||
return {
|
||||
key: 'updates',
|
||||
label: 'Update unknown',
|
||||
detail: 'Pulse cannot yet compare this agent with the target version.',
|
||||
tone: 'muted',
|
||||
};
|
||||
case 'not-applicable':
|
||||
return {
|
||||
key: 'updates',
|
||||
label: 'No agent update',
|
||||
detail: 'This source is not updated through the Pulse Agent binary rollout.',
|
||||
tone: 'muted',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const remoteControlSignal = (state: ConnectionFleetRemoteControl): FleetGovernanceSignal => {
|
||||
switch (state) {
|
||||
case 'enabled':
|
||||
return {
|
||||
key: 'remote-control',
|
||||
label: 'Remote control enabled',
|
||||
detail: 'Pulse command execution is enabled for this agent.',
|
||||
tone: 'info',
|
||||
};
|
||||
case 'disabled':
|
||||
return {
|
||||
key: 'remote-control',
|
||||
label: 'Remote control off',
|
||||
detail: 'Pulse command execution is disabled for this agent.',
|
||||
tone: 'muted',
|
||||
};
|
||||
case 'not-applicable':
|
||||
return {
|
||||
key: 'remote-control',
|
||||
label: 'No remote control',
|
||||
detail: 'This source does not use Pulse Agent command execution.',
|
||||
tone: 'muted',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const fleetGovernanceSignalsForConnection = (
|
||||
connection: Connection,
|
||||
): FleetGovernanceSignal[] => {
|
||||
const fleet = fleetGovernanceForConnection(connection);
|
||||
return [
|
||||
enrollmentSignal(fleet.enrollmentState, connection),
|
||||
livenessSignal(fleet.livenessState),
|
||||
versionSignal(fleet.versionDrift),
|
||||
adapterSignal(fleet.adapterHealth),
|
||||
configSignal(fleet.configRollout),
|
||||
credentialSignal(fleet.credentialStatus),
|
||||
updateSignal(fleet.updateStatus),
|
||||
remoteControlSignal(fleet.remoteControl),
|
||||
];
|
||||
};
|
||||
|
||||
export const visibleFleetGovernanceSignals = (
|
||||
signals: readonly FleetGovernanceSignal[],
|
||||
): FleetGovernanceSignal[] => {
|
||||
const attention = signals.filter(
|
||||
(signal) => signal.tone === 'critical' || signal.tone === 'warning',
|
||||
);
|
||||
const control = signals.filter((signal) => signal.tone === 'info');
|
||||
if (attention.length + control.length > 0) {
|
||||
return [...attention, ...control].slice(0, 3);
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: 'liveness',
|
||||
label: 'Fleet OK',
|
||||
detail:
|
||||
'Enrollment, liveness, adapter health, credentials, and rollout state have no current warnings.',
|
||||
tone: 'ok',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export interface InfrastructureSystemMemberRow {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -206,6 +584,8 @@ export interface InfrastructureSystemMemberRow {
|
|||
statusLabel: string;
|
||||
statusClassName: string;
|
||||
lastActivityText: string;
|
||||
fleetSignals: FleetGovernanceSignal[];
|
||||
fleetHighlights: FleetGovernanceSignal[];
|
||||
primary: boolean;
|
||||
agentConnection?: Connection;
|
||||
}
|
||||
|
|
@ -224,6 +604,8 @@ export interface InfrastructureSystemRow {
|
|||
agentUpdateCount: number;
|
||||
lastActivityText: string;
|
||||
lastErrorMessage?: string;
|
||||
fleetSignals: FleetGovernanceSignal[];
|
||||
fleetHighlights: FleetGovernanceSignal[];
|
||||
enabled: boolean;
|
||||
canEdit: boolean;
|
||||
canPause: boolean;
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import {
|
|||
connectionAgentEndpointDisplay,
|
||||
connectionAgentIdentitySummary,
|
||||
connectionLastActivityText,
|
||||
fleetGovernanceSignalsForConnection,
|
||||
lastActivityTextFromLastSeen,
|
||||
surfaceLabel,
|
||||
type InfrastructureSourceKind,
|
||||
type InfrastructureSystemMemberRow,
|
||||
type InfrastructureSystemRow,
|
||||
visibleFleetGovernanceSignals,
|
||||
} from './connectionsTableModel';
|
||||
|
||||
export { surfaceLabel };
|
||||
|
|
@ -144,9 +146,7 @@ const moreSevereState = (
|
|||
return CONNECTION_STATE_SEVERITY[right] > CONNECTION_STATE_SEVERITY[left] ? right : left;
|
||||
};
|
||||
|
||||
const oldestTimestamp = (
|
||||
values: ReadonlyArray<string | null | undefined>,
|
||||
): string | undefined => {
|
||||
const oldestTimestamp = (values: ReadonlyArray<string | null | undefined>): string | undefined => {
|
||||
let oldestMs: number | undefined;
|
||||
let oldestRaw: string | undefined;
|
||||
for (const value of values) {
|
||||
|
|
@ -186,6 +186,7 @@ const buildMemberRow = (
|
|||
agentConnection && state === agentConnection.state
|
||||
? agentConnection.lastSeen
|
||||
: (member.lastSeen ?? agentConnection?.lastSeen);
|
||||
const fleetSignals = agentConnection ? fleetGovernanceSignalsForConnection(agentConnection) : [];
|
||||
|
||||
return {
|
||||
id: member.id,
|
||||
|
|
@ -198,6 +199,8 @@ const buildMemberRow = (
|
|||
statusLabel: presentation.label,
|
||||
statusClassName: presentation.badgeClass,
|
||||
lastActivityText: lastActivityTextFromLastSeen(lastSeen),
|
||||
fleetSignals,
|
||||
fleetHighlights: visibleFleetGovernanceSignals(fleetSignals),
|
||||
primary: Boolean(member.primary),
|
||||
agentConnection,
|
||||
};
|
||||
|
|
@ -244,6 +247,9 @@ const buildRow = (
|
|||
const identitySubtitle = isStandaloneAgent
|
||||
? (connectionAgentIdentitySummary(primaryConnection) ?? undefined)
|
||||
: undefined;
|
||||
const fleetSignals = componentConnections.flatMap((connection) =>
|
||||
fleetGovernanceSignalsForConnection(connection),
|
||||
);
|
||||
|
||||
return {
|
||||
id: primaryConnection.id,
|
||||
|
|
@ -261,6 +267,8 @@ const buildRow = (
|
|||
? lastActivityTextFromLastSeen(rollup.lastSeen)
|
||||
: connectionLastActivityText(primaryConnection),
|
||||
lastErrorMessage,
|
||||
fleetSignals,
|
||||
fleetHighlights: visibleFleetGovernanceSignals(fleetSignals),
|
||||
enabled: primaryConnection.enabled,
|
||||
canEdit: EDITABLE_CONNECTION_TYPES.includes(primaryConnection.type),
|
||||
canPause: primaryConnection.capabilities.supportsPause,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,27 @@ const connectionStaleThreshold = 2 * time.Minute
|
|||
// state-derivation stays the same across types.
|
||||
var connectionAuthErrorPattern = regexp.MustCompile(`(?i)401|403|unauthori[sz]ed|forbidden|authentication|permission denied|invalid (credentials|token|api key)`)
|
||||
|
||||
const (
|
||||
fleetStateActive = "active"
|
||||
fleetStateBehind = "behind"
|
||||
fleetStateBlocked = "blocked"
|
||||
fleetStateConfigured = "configured"
|
||||
fleetStateCurrent = "current"
|
||||
fleetStateDegraded = "degraded"
|
||||
fleetStateDisabled = "disabled"
|
||||
fleetStateEnabled = "enabled"
|
||||
fleetStateEnrolled = "enrolled"
|
||||
fleetStateHealthy = "healthy"
|
||||
fleetStateInvalid = "invalid"
|
||||
fleetStateNotApplicable = "not-applicable"
|
||||
fleetStatePaused = "paused"
|
||||
fleetStatePending = "pending"
|
||||
fleetStateReported = "reported"
|
||||
fleetStateUnknown = "unknown"
|
||||
fleetStateUpdateAvailable = "update-available"
|
||||
fleetStateVerified = "verified"
|
||||
)
|
||||
|
||||
// aggregatorInputs bundles everything the aggregator reads. Separating inputs
|
||||
// from the handler makes the aggregator unit-testable without spinning up a
|
||||
// monitor or persistence layer.
|
||||
|
|
@ -93,7 +114,7 @@ func buildPVEConnection(inst config.PVEInstance, health map[string]monitoring.In
|
|||
}
|
||||
h := health["pve::"+inst.Name]
|
||||
state, reason, lastSeen, lastError := deriveConnectionState(enabled, h, now)
|
||||
return Connection{
|
||||
return withFleetGovernance(Connection{
|
||||
ID: "pve:" + inst.Name,
|
||||
Type: ConnectionTypePVE,
|
||||
Name: inst.Name,
|
||||
|
|
@ -108,7 +129,7 @@ func buildPVEConnection(inst config.PVEInstance, health map[string]monitoring.In
|
|||
LastError: lastError,
|
||||
Source: sourceFromString(inst.Source),
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func buildPBSConnection(inst config.PBSInstance, health map[string]monitoring.InstanceHealth, now time.Time) Connection {
|
||||
|
|
@ -124,7 +145,7 @@ func buildPBSConnection(inst config.PBSInstance, health map[string]monitoring.In
|
|||
}
|
||||
h := health["pbs::"+inst.Name]
|
||||
state, reason, lastSeen, lastError := deriveConnectionState(enabled, h, now)
|
||||
return Connection{
|
||||
return withFleetGovernance(Connection{
|
||||
ID: "pbs:" + inst.Name,
|
||||
Type: ConnectionTypePBS,
|
||||
Name: inst.Name,
|
||||
|
|
@ -139,7 +160,7 @@ func buildPBSConnection(inst config.PBSInstance, health map[string]monitoring.In
|
|||
LastError: lastError,
|
||||
Source: sourceFromString(inst.Source),
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func buildPMGConnection(inst config.PMGInstance, health map[string]monitoring.InstanceHealth, now time.Time) Connection {
|
||||
|
|
@ -153,7 +174,7 @@ func buildPMGConnection(inst config.PMGInstance, health map[string]monitoring.In
|
|||
}
|
||||
h := health["pmg::"+inst.Name]
|
||||
state, reason, lastSeen, lastError := deriveConnectionState(enabled, h, now)
|
||||
return Connection{
|
||||
return withFleetGovernance(Connection{
|
||||
ID: "pmg:" + inst.Name,
|
||||
Type: ConnectionTypePMG,
|
||||
Name: inst.Name,
|
||||
|
|
@ -168,7 +189,7 @@ func buildPMGConnection(inst config.PMGInstance, health map[string]monitoring.In
|
|||
LastError: lastError,
|
||||
Source: ConnectionSourceManual,
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func buildVMwareConnection(inst config.VMwareVCenterInstance, health map[string]monitoring.InstanceHealth, now time.Time) Connection {
|
||||
|
|
@ -185,7 +206,7 @@ func buildVMwareConnection(inst config.VMwareVCenterInstance, health map[string]
|
|||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
return Connection{
|
||||
return withFleetGovernance(Connection{
|
||||
ID: "vmware:" + inst.ID,
|
||||
Type: ConnectionTypeVMware,
|
||||
Name: inst.Name,
|
||||
|
|
@ -200,7 +221,7 @@ func buildVMwareConnection(inst config.VMwareVCenterInstance, health map[string]
|
|||
LastError: lastError,
|
||||
Source: ConnectionSourceManual,
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func buildTrueNASConnection(inst config.TrueNASInstance, health map[string]monitoring.InstanceHealth, now time.Time) Connection {
|
||||
|
|
@ -225,7 +246,7 @@ func buildTrueNASConnection(inst config.TrueNASInstance, health map[string]monit
|
|||
port = 80
|
||||
}
|
||||
}
|
||||
return Connection{
|
||||
return withFleetGovernance(Connection{
|
||||
ID: "truenas:" + inst.ID,
|
||||
Type: ConnectionTypeTrueNAS,
|
||||
Name: inst.Name,
|
||||
|
|
@ -240,7 +261,7 @@ func buildTrueNASConnection(inst config.TrueNASInstance, health map[string]monit
|
|||
LastError: lastError,
|
||||
Source: ConnectionSourceManual,
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// buildAgentConnection derives a connection row from an agent Host record.
|
||||
|
|
@ -284,7 +305,7 @@ func buildAgentConnection(host models.Host, expectedAgentVersion string, now tim
|
|||
state = ConnectionStateActive
|
||||
}
|
||||
|
||||
return Connection{
|
||||
return withFleetGovernance(Connection{
|
||||
ID: "agent:" + host.ID,
|
||||
Type: ConnectionTypeAgent,
|
||||
Name: name,
|
||||
|
|
@ -303,9 +324,117 @@ func buildAgentConnection(host models.Host, expectedAgentVersion string, now tim
|
|||
ExpectedAgentVersion: expectedAgentVersion,
|
||||
AgentUpdateAvailable: updateAvailable,
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: false, SupportsScope: false, SupportsTest: false},
|
||||
})
|
||||
}
|
||||
|
||||
func withFleetGovernance(conn Connection) Connection {
|
||||
conn.Fleet = deriveConnectionFleetGovernance(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
func deriveConnectionFleetGovernance(conn Connection) ConnectionFleetGovernance {
|
||||
return ConnectionFleetGovernance{
|
||||
EnrollmentState: connectionFleetEnrollmentState(conn),
|
||||
LivenessState: string(conn.State),
|
||||
VersionDrift: connectionFleetVersionDrift(conn),
|
||||
AdapterHealth: connectionFleetAdapterHealth(conn),
|
||||
ConfigRollout: connectionFleetConfigRollout(conn),
|
||||
CredentialStatus: connectionFleetCredentialStatus(conn),
|
||||
UpdateStatus: connectionFleetUpdateStatus(conn),
|
||||
RemoteControl: connectionFleetRemoteControl(conn),
|
||||
}
|
||||
}
|
||||
|
||||
func connectionFleetEnrollmentState(conn Connection) string {
|
||||
if conn.Type == ConnectionTypeAgent {
|
||||
if conn.LastSeen == nil {
|
||||
return fleetStatePending
|
||||
}
|
||||
return fleetStateEnrolled
|
||||
}
|
||||
if !conn.Enabled {
|
||||
return fleetStatePaused
|
||||
}
|
||||
return fleetStateConfigured
|
||||
}
|
||||
|
||||
func connectionFleetVersionDrift(conn Connection) string {
|
||||
if conn.Type != ConnectionTypeAgent {
|
||||
return fleetStateNotApplicable
|
||||
}
|
||||
if strings.TrimSpace(conn.AgentVersion) == "" || strings.TrimSpace(conn.ExpectedAgentVersion) == "" {
|
||||
return fleetStateUnknown
|
||||
}
|
||||
if conn.AgentUpdateAvailable {
|
||||
return fleetStateBehind
|
||||
}
|
||||
return fleetStateCurrent
|
||||
}
|
||||
|
||||
func connectionFleetAdapterHealth(conn Connection) string {
|
||||
switch conn.State {
|
||||
case ConnectionStateActive:
|
||||
return fleetStateHealthy
|
||||
case ConnectionStateStale, ConnectionStatePending:
|
||||
return fleetStateDegraded
|
||||
case ConnectionStateUnauthorized, ConnectionStateUnreachable:
|
||||
return fleetStateBlocked
|
||||
case ConnectionStatePaused:
|
||||
return fleetStatePaused
|
||||
default:
|
||||
return fleetStateUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func connectionFleetConfigRollout(conn Connection) string {
|
||||
if !conn.Enabled || conn.State == ConnectionStatePaused {
|
||||
return fleetStatePaused
|
||||
}
|
||||
if conn.Type != ConnectionTypeAgent {
|
||||
return fleetStateConfigured
|
||||
}
|
||||
if conn.LastSeen == nil {
|
||||
return fleetStateUnknown
|
||||
}
|
||||
return fleetStateReported
|
||||
}
|
||||
|
||||
func connectionFleetCredentialStatus(conn Connection) string {
|
||||
switch conn.State {
|
||||
case ConnectionStateUnauthorized:
|
||||
return fleetStateInvalid
|
||||
case ConnectionStatePending:
|
||||
return fleetStateUnknown
|
||||
case ConnectionStatePaused:
|
||||
return fleetStatePaused
|
||||
default:
|
||||
return fleetStateVerified
|
||||
}
|
||||
}
|
||||
|
||||
func connectionFleetUpdateStatus(conn Connection) string {
|
||||
if conn.Type != ConnectionTypeAgent {
|
||||
return fleetStateNotApplicable
|
||||
}
|
||||
if conn.AgentUpdateAvailable {
|
||||
return fleetStateUpdateAvailable
|
||||
}
|
||||
if strings.TrimSpace(conn.AgentVersion) == "" || strings.TrimSpace(conn.ExpectedAgentVersion) == "" {
|
||||
return fleetStateUnknown
|
||||
}
|
||||
return fleetStateCurrent
|
||||
}
|
||||
|
||||
func connectionFleetRemoteControl(conn Connection) string {
|
||||
if conn.Type != ConnectionTypeAgent {
|
||||
return fleetStateNotApplicable
|
||||
}
|
||||
if conn.AgentIdentity != nil && conn.AgentIdentity.CommandsEnabled {
|
||||
return fleetStateEnabled
|
||||
}
|
||||
return fleetStateDisabled
|
||||
}
|
||||
|
||||
func connectionAgentIdentityForHost(host models.Host) *ConnectionAgentIdentity {
|
||||
identity := &ConnectionAgentIdentity{
|
||||
Hostname: strings.TrimSpace(host.Hostname),
|
||||
|
|
|
|||
|
|
@ -268,6 +268,117 @@ func TestBuildConnections_AgentVersionUpdateAvailability(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildConnections_AgentFleetGovernance(t *testing.T) {
|
||||
now := time.Now()
|
||||
in := aggregatorInputs{
|
||||
hosts: []models.Host{
|
||||
{
|
||||
ID: "current",
|
||||
Hostname: "current",
|
||||
LastSeen: now,
|
||||
AgentVersion: "6.0.2",
|
||||
CommandsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "outdated",
|
||||
Hostname: "outdated",
|
||||
LastSeen: now,
|
||||
AgentVersion: "6.0.0",
|
||||
},
|
||||
{
|
||||
ID: "pending",
|
||||
Hostname: "pending",
|
||||
},
|
||||
},
|
||||
expectedAgentVersion: "6.0.2",
|
||||
now: now,
|
||||
}
|
||||
|
||||
got := buildConnections(in)
|
||||
byID := map[string]Connection{}
|
||||
for _, connection := range got {
|
||||
byID[connection.ID] = connection
|
||||
}
|
||||
|
||||
current := byID["agent:current"].Fleet
|
||||
if current.EnrollmentState != fleetStateEnrolled ||
|
||||
current.LivenessState != fleetStateActive ||
|
||||
current.VersionDrift != fleetStateCurrent ||
|
||||
current.AdapterHealth != fleetStateHealthy ||
|
||||
current.ConfigRollout != fleetStateReported ||
|
||||
current.CredentialStatus != fleetStateVerified ||
|
||||
current.UpdateStatus != fleetStateCurrent ||
|
||||
current.RemoteControl != fleetStateEnabled {
|
||||
t.Fatalf("current agent fleet governance = %+v", current)
|
||||
}
|
||||
|
||||
outdated := byID["agent:outdated"].Fleet
|
||||
if outdated.VersionDrift != fleetStateBehind ||
|
||||
outdated.UpdateStatus != fleetStateUpdateAvailable ||
|
||||
outdated.RemoteControl != fleetStateDisabled {
|
||||
t.Fatalf("outdated agent fleet governance = %+v", outdated)
|
||||
}
|
||||
|
||||
pending := byID["agent:pending"].Fleet
|
||||
if pending.EnrollmentState != fleetStatePending ||
|
||||
pending.LivenessState != string(ConnectionStatePending) ||
|
||||
pending.AdapterHealth != fleetStateDegraded ||
|
||||
pending.ConfigRollout != fleetStateUnknown ||
|
||||
pending.CredentialStatus != fleetStateUnknown {
|
||||
t.Fatalf("pending agent fleet governance = %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnections_PlatformFleetGovernance(t *testing.T) {
|
||||
now := time.Now()
|
||||
lastSuccess := now.Add(-30 * time.Second)
|
||||
in := aggregatorInputs{
|
||||
pveInstances: []config.PVEInstance{
|
||||
{Name: "healthy", Host: "https://healthy.lan:8006"},
|
||||
{Name: "bad-token", Host: "https://bad-token.lan:8006"},
|
||||
{Name: "paused", Host: "https://paused.lan:8006", Disabled: true},
|
||||
},
|
||||
instanceHealth: map[string]monitoring.InstanceHealth{
|
||||
"pve::healthy": healthEntry(&lastSuccess, "", "", "closed"),
|
||||
"pve::bad-token": healthEntry(&lastSuccess, "403 forbidden", "auth", "closed"),
|
||||
},
|
||||
now: now,
|
||||
}
|
||||
|
||||
got := buildConnections(in)
|
||||
byID := map[string]Connection{}
|
||||
for _, connection := range got {
|
||||
byID[connection.ID] = connection
|
||||
}
|
||||
|
||||
healthy := byID["pve:healthy"].Fleet
|
||||
if healthy.EnrollmentState != fleetStateConfigured ||
|
||||
healthy.LivenessState != fleetStateActive ||
|
||||
healthy.VersionDrift != fleetStateNotApplicable ||
|
||||
healthy.AdapterHealth != fleetStateHealthy ||
|
||||
healthy.ConfigRollout != fleetStateConfigured ||
|
||||
healthy.CredentialStatus != fleetStateVerified ||
|
||||
healthy.UpdateStatus != fleetStateNotApplicable ||
|
||||
healthy.RemoteControl != fleetStateNotApplicable {
|
||||
t.Fatalf("healthy platform fleet governance = %+v", healthy)
|
||||
}
|
||||
|
||||
badToken := byID["pve:bad-token"].Fleet
|
||||
if badToken.AdapterHealth != fleetStateBlocked ||
|
||||
badToken.CredentialStatus != fleetStateInvalid ||
|
||||
badToken.LivenessState != string(ConnectionStateUnauthorized) {
|
||||
t.Fatalf("bad token fleet governance = %+v", badToken)
|
||||
}
|
||||
|
||||
paused := byID["pve:paused"].Fleet
|
||||
if paused.EnrollmentState != fleetStatePaused ||
|
||||
paused.AdapterHealth != fleetStatePaused ||
|
||||
paused.ConfigRollout != fleetStatePaused ||
|
||||
paused.CredentialStatus != fleetStatePaused {
|
||||
t.Fatalf("paused platform fleet governance = %+v", paused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnections_VMwareAndTrueNASEnabledFlag(t *testing.T) {
|
||||
in := aggregatorInputs{
|
||||
vmwareInstances: []config.VMwareVCenterInstance{{
|
||||
|
|
|
|||
|
|
@ -50,6 +50,20 @@ type ConnectionCapabilities struct {
|
|||
SupportsTest bool `json:"supportsTest"`
|
||||
}
|
||||
|
||||
// ConnectionFleetGovernance is the canonical fleet-control projection for a
|
||||
// connection row. Every field is derived from existing connection/runtime
|
||||
// signals; the connections ledger does not persist a second fleet registry.
|
||||
type ConnectionFleetGovernance struct {
|
||||
EnrollmentState string `json:"enrollmentState"`
|
||||
LivenessState string `json:"livenessState"`
|
||||
VersionDrift string `json:"versionDrift"`
|
||||
AdapterHealth string `json:"adapterHealth"`
|
||||
ConfigRollout string `json:"configRollout"`
|
||||
CredentialStatus string `json:"credentialStatus"`
|
||||
UpdateStatus string `json:"updateStatus"`
|
||||
RemoteControl string `json:"remoteControl"`
|
||||
}
|
||||
|
||||
// ConnectionError is the runtime error shape surfaced on a connection row.
|
||||
// Mirrors monitoring.ErrorDetail but lives in the api package so the type
|
||||
// stays stable if the internal monitoring shape evolves.
|
||||
|
|
@ -78,24 +92,25 @@ type ConnectionAgentIdentity struct {
|
|||
// per-type shapes that today require separate fetches and separate table
|
||||
// renderers.
|
||||
type Connection struct {
|
||||
ID string `json:"id"`
|
||||
Type ConnectionType `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
HostAliases []string `json:"hostAliases,omitempty"`
|
||||
State ConnectionState `json:"state"`
|
||||
StateReason string `json:"stateReason,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Surfaces []string `json:"surfaces"`
|
||||
Scope map[string]bool `json:"scope"`
|
||||
LastSeen *time.Time `json:"lastSeen,omitempty"`
|
||||
LastError *ConnectionError `json:"lastError,omitempty"`
|
||||
Source ConnectionSource `json:"source"`
|
||||
AgentIdentity *ConnectionAgentIdentity `json:"agentIdentity,omitempty"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
ExpectedAgentVersion string `json:"expectedAgentVersion,omitempty"`
|
||||
AgentUpdateAvailable bool `json:"agentUpdateAvailable,omitempty"`
|
||||
Capabilities ConnectionCapabilities `json:"capabilities"`
|
||||
ID string `json:"id"`
|
||||
Type ConnectionType `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
HostAliases []string `json:"hostAliases,omitempty"`
|
||||
State ConnectionState `json:"state"`
|
||||
StateReason string `json:"stateReason,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Surfaces []string `json:"surfaces"`
|
||||
Scope map[string]bool `json:"scope"`
|
||||
LastSeen *time.Time `json:"lastSeen,omitempty"`
|
||||
LastError *ConnectionError `json:"lastError,omitempty"`
|
||||
Source ConnectionSource `json:"source"`
|
||||
AgentIdentity *ConnectionAgentIdentity `json:"agentIdentity,omitempty"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
ExpectedAgentVersion string `json:"expectedAgentVersion,omitempty"`
|
||||
AgentUpdateAvailable bool `json:"agentUpdateAvailable,omitempty"`
|
||||
Fleet ConnectionFleetGovernance `json:"fleet"`
|
||||
Capabilities ConnectionCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
type ConnectionSystemComponentRole string
|
||||
|
|
|
|||
|
|
@ -11652,18 +11652,28 @@ func TestContract_ShippedSecurityDocReferencesStayLocal(t *testing.T) {
|
|||
// commit.
|
||||
func TestContract_ConnectionPayloadShapeStaysCanonical(t *testing.T) {
|
||||
conn := Connection{
|
||||
ID: "pve-lab",
|
||||
Type: ConnectionTypePVE,
|
||||
Name: "lab",
|
||||
Address: "https://pve.lab:8006",
|
||||
State: ConnectionStateActive,
|
||||
StateReason: "",
|
||||
Enabled: true,
|
||||
Surfaces: []string{"vms", "containers"},
|
||||
Scope: map[string]bool{"vms": true, "containers": true},
|
||||
LastSeen: timePtr(time.Date(2026, 4, 19, 10, 0, 0, 0, time.UTC)),
|
||||
LastError: nil,
|
||||
Source: ConnectionSourceManual,
|
||||
ID: "pve-lab",
|
||||
Type: ConnectionTypePVE,
|
||||
Name: "lab",
|
||||
Address: "https://pve.lab:8006",
|
||||
State: ConnectionStateActive,
|
||||
StateReason: "",
|
||||
Enabled: true,
|
||||
Surfaces: []string{"vms", "containers"},
|
||||
Scope: map[string]bool{"vms": true, "containers": true},
|
||||
LastSeen: timePtr(time.Date(2026, 4, 19, 10, 0, 0, 0, time.UTC)),
|
||||
LastError: nil,
|
||||
Source: ConnectionSourceManual,
|
||||
Fleet: ConnectionFleetGovernance{
|
||||
EnrollmentState: fleetStateConfigured,
|
||||
LivenessState: fleetStateActive,
|
||||
VersionDrift: fleetStateNotApplicable,
|
||||
AdapterHealth: fleetStateHealthy,
|
||||
ConfigRollout: fleetStateConfigured,
|
||||
CredentialStatus: fleetStateVerified,
|
||||
UpdateStatus: fleetStateNotApplicable,
|
||||
RemoteControl: fleetStateNotApplicable,
|
||||
},
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
|
||||
}
|
||||
system := ConnectionSystem{
|
||||
|
|
@ -11685,7 +11695,7 @@ func TestContract_ConnectionPayloadShapeStaysCanonical(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("marshal Connection: %v", err)
|
||||
}
|
||||
want := `{"connections":[{"id":"pve-lab","type":"pve","name":"lab","address":"https://pve.lab:8006","state":"active","enabled":true,"surfaces":["vms","containers"],"scope":{"containers":true,"vms":true},"lastSeen":"2026-04-19T10:00:00Z","source":"manual","capabilities":{"supportsPause":true,"supportsScope":true,"supportsTest":true}}],"systems":[{"id":"pve-lab","type":"pve","clusterName":"homelab","components":[{"connectionId":"pve-lab","type":"pve","role":"primary"}]}]}`
|
||||
want := `{"connections":[{"id":"pve-lab","type":"pve","name":"lab","address":"https://pve.lab:8006","state":"active","enabled":true,"surfaces":["vms","containers"],"scope":{"containers":true,"vms":true},"lastSeen":"2026-04-19T10:00:00Z","source":"manual","fleet":{"enrollmentState":"configured","livenessState":"active","versionDrift":"not-applicable","adapterHealth":"healthy","configRollout":"configured","credentialStatus":"verified","updateStatus":"not-applicable","remoteControl":"not-applicable"},"capabilities":{"supportsPause":true,"supportsScope":true,"supportsTest":true}}],"systems":[{"id":"pve-lab","type":"pve","clusterName":"homelab","components":[{"connectionId":"pve-lab","type":"pve","role":"primary"}]}]}`
|
||||
assertJSONSnapshot(t, body, want)
|
||||
}
|
||||
|
||||
|
|
@ -11763,6 +11773,16 @@ func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) {
|
|||
AgentVersion: "6.0.0",
|
||||
ExpectedAgentVersion: "6.0.2",
|
||||
AgentUpdateAvailable: true,
|
||||
Fleet: ConnectionFleetGovernance{
|
||||
EnrollmentState: fleetStateEnrolled,
|
||||
LivenessState: fleetStateActive,
|
||||
VersionDrift: fleetStateBehind,
|
||||
AdapterHealth: fleetStateHealthy,
|
||||
ConfigRollout: fleetStateReported,
|
||||
CredentialStatus: fleetStateVerified,
|
||||
UpdateStatus: fleetStateUpdateAvailable,
|
||||
RemoteControl: fleetStateEnabled,
|
||||
},
|
||||
Capabilities: ConnectionCapabilities{
|
||||
SupportsPause: false,
|
||||
SupportsScope: false,
|
||||
|
|
@ -11775,7 +11795,7 @@ func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) {
|
|||
t.Fatalf("marshal agent Connection: %v", err)
|
||||
}
|
||||
|
||||
want := `{"id":"agent:host-1","type":"agent","name":"host-1","address":"host-1","hostAliases":["host-1","192.168.0.2"],"state":"active","enabled":true,"surfaces":["host"],"scope":{"host":true},"lastSeen":"2026-04-22T12:00:00Z","source":"agent","agentIdentity":{"hostname":"host-1","platform":"unraid","osName":"Unraid","osVersion":"7.1.0","kernelVersion":"6.12.0","architecture":"x86_64","reportIp":"192.168.0.2","commandsEnabled":true},"agentVersion":"6.0.0","expectedAgentVersion":"6.0.2","agentUpdateAvailable":true,"capabilities":{"supportsPause":false,"supportsScope":false,"supportsTest":false}}`
|
||||
want := `{"id":"agent:host-1","type":"agent","name":"host-1","address":"host-1","hostAliases":["host-1","192.168.0.2"],"state":"active","enabled":true,"surfaces":["host"],"scope":{"host":true},"lastSeen":"2026-04-22T12:00:00Z","source":"agent","agentIdentity":{"hostname":"host-1","platform":"unraid","osName":"Unraid","osVersion":"7.1.0","kernelVersion":"6.12.0","architecture":"x86_64","reportIp":"192.168.0.2","commandsEnabled":true},"agentVersion":"6.0.0","expectedAgentVersion":"6.0.2","agentUpdateAvailable":true,"fleet":{"enrollmentState":"enrolled","livenessState":"active","versionDrift":"behind","adapterHealth":"healthy","configRollout":"reported","credentialStatus":"verified","updateStatus":"update-available","remoteControl":"enabled"},"capabilities":{"supportsPause":false,"supportsScope":false,"supportsTest":false}}`
|
||||
assertJSONSnapshot(t, body, want)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
{"v6-rc-cut", "v6-rc-stabilization", "v6-ga-promotion", "v6-product-lane-expansion"},
|
||||
)
|
||||
self.assertEqual(result["scope"]["control_plane_repo"], "pulse")
|
||||
self.assertEqual(result["status_summary"]["lane_count"], 21)
|
||||
self.assertEqual(result["status_summary"]["lane_count"], 22)
|
||||
|
||||
file_entry = result["files"][0]
|
||||
matches = {match["subsystem"] for match in file_entry["matches"]}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue