Don't surface agent-fleet governance on pull-based API sources

An unreachable PBS connection rendered its problem line as 'Rollout
blocked' — Pulse Agent rollout terminology that makes no sense for a
pull-based API source that simply can't be reached. The backend echoes
rollout/config governance state onto every connection, and because the
liveness signal is intentionally filtered out of the highlight list
(it duplicates the status badge), primaryRowProblem fell through to the
agent rollout signal.

Gate the agent-binary / managed-config governance signals (version
drift, config drift, staged rollout, binary updates, remote command
policy) on the connection actually running a Pulse Agent / collector
(agent, docker, kubernetes). Pull-based API sources (pve, pbs, pmg,
vmware, truenas, availability) now only carry source-agnostic posture
(enrollment, liveness, credential health, adapter), so an unreachable
PBS reflects its reachability instead of fictitious rollout state.
Verified against real infra: no API source shows rollout/config/version
language; the only problem lines are legitimate 'Agent offline'
attachment messages.

Also reconcile tests with two intentional UI changes from the prior
infrastructure-table commits that the pre-commit hook (lint only, no
vitest) did not catch:
- The row-level install-agent shortcut is now an icon button; name it
  'Install agent' so it stays distinct from the group-level 'Install
  Pulse Agent' action the tests assert against.
- The system name's OS/identity and cluster/member descriptors moved
  into the name tooltip to keep rows single-line, so the workspace
  tests assert them via title instead of visible text.
This commit is contained in:
rcourtman 2026-05-30 18:52:51 +01:00
parent ab9a439f2c
commit 4f52ef88da
5 changed files with 107 additions and 18 deletions

View file

@ -889,7 +889,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
type="button"
onClick={handleInstallAgentShortcut}
class="inline-flex items-center justify-center rounded p-1 text-blue-700 transition-colors hover:bg-blue-50 dark:text-blue-300 dark:hover:bg-blue-950/30"
aria-label="Install Pulse Agent on this system"
aria-label="Install agent"
title="Install Pulse Agent on this system to add node-local telemetry (temperatures, SMART, host identity)."
>
<Plus class="h-3.5 w-3.5" />

View file

@ -800,7 +800,10 @@ describe('InfrastructureWorkspace', () => {
await waitFor(() => expect(screen.getByText('Unraid')).toBeInTheDocument());
expect(screen.queryByText('Pulse Agent hosts')).toBeNull();
expect(screen.getByText('Tower')).toBeInTheDocument();
expect(screen.getByText('Unraid 7.1.0')).toBeInTheDocument();
// The OS/identity descriptor now rides in the system name's tooltip so the
// table row stays single-line; the agent detail drawer still shows it as
// visible text (asserted below).
expect(screen.getByTitle('Tower · Unraid 7.1.0')).toBeInTheDocument();
expect(screen.getByText('192.168.0.10')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^Add Unraid$/i }));
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure?add=unraid', {
@ -996,9 +999,11 @@ describe('InfrastructureWorkspace', () => {
expect(within(readiness).getByText('Needs attention').nextElementSibling).toHaveTextContent(
'0 systems',
);
expect(screen.getByText('Cluster · 2 nodes')).toBeInTheDocument();
// Cluster and member descriptors moved into the system name's tooltip to
// keep table rows single-line.
expect(screen.getByTitle('homelab · Cluster · 2 nodes')).toBeInTheDocument();
expect(screen.queryByText('Fleet OK')).toBeNull();
expect(screen.getByText('Primary node')).toBeInTheDocument();
expect(screen.getByTitle('delly · Primary node')).toBeInTheDocument();
expect(screen.getAllByText('Agent').length).toBeGreaterThan(0);
expect(screen.getByText('delly')).toBeInTheDocument();
expect(screen.getByText('minipc')).toBeInTheDocument();

View file

@ -213,6 +213,66 @@ describe('visibleFleetGovernanceSignals', () => {
});
});
describe('fleetGovernanceSignalsForConnection connection-type gating', () => {
it('omits agent-fleet governance signals for pull-based API sources', () => {
const signals = fleetGovernanceSignalsForConnection(
connectionFixture({
id: 'pbs:pbs-docker',
type: 'pbs',
name: 'pbs-docker',
state: 'unreachable',
source: 'manual',
fleet: {
enrollmentState: 'configured',
livenessState: 'unreachable',
versionDrift: 'unknown',
adapterHealth: 'blocked',
configRollout: 'unknown',
credentialStatus: 'unknown',
updateStatus: 'unknown',
remoteControl: 'not-applicable',
rollout: { status: 'blocked', reason: 'blocked by the current connection state' },
},
}),
);
const keys = signals.map((signal) => signal.key);
// The backend echoed a blocked rollout, but an API source has no agent, so
// none of the agent-binary / managed-config governance signals must surface
// (this is what produced the misleading "Rollout blocked" problem line).
expect(signals.map((signal) => signal.label)).not.toContain('Rollout blocked');
expect(keys).not.toContain('rollout');
expect(keys).not.toContain('config-drift');
expect(keys).not.toContain('version');
expect(keys).not.toContain('updates');
expect(keys).not.toContain('command-policy');
// Source-agnostic signals still apply to API sources.
expect(keys).toContain('enrollment');
expect(keys).toContain('liveness');
expect(keys).toContain('credential-health');
});
it('keeps agent-fleet governance signals for agent connections', () => {
const keys = fleetGovernanceSignalsForConnection(
connectionFixture({
fleet: {
enrollmentState: 'enrolled',
livenessState: 'active',
versionDrift: 'behind',
adapterHealth: 'healthy',
configRollout: 'reported',
credentialStatus: 'verified',
updateStatus: 'update-available',
remoteControl: 'disabled',
rollout: { status: 'blocked', reason: 'staged rollout halted' },
},
}),
).map((signal) => signal.key);
expect(keys).toContain('rollout');
expect(keys).toContain('version');
expect(keys).toContain('command-policy');
});
});
describe('connectionAgentIdentitySummary', () => {
const withAgentIdentity = (
agentIdentity: Partial<ConnectionAgentIdentity> | undefined,

View file

@ -490,13 +490,15 @@ describe('useConnectionsLedger', () => {
'Rollout pending',
'Version behind',
]);
// Pull-based API sources (PVE/PBS/etc.) have no Pulse Agent, so agent-fleet
// governance (rollout/config/version/command-policy) must not surface on
// them. Only source-agnostic posture like credential health applies; an
// invalid PVE token still reads "Credentials invalid", and a paused PVE
// connection surfaces no fleet highlight at all.
expect(byID.get('pve:invalid')?.fleetHighlights.map((signal) => signal.label)).toEqual([
'Credentials invalid',
'Rollout blocked',
]);
expect(byID.get('pve:paused')?.fleetHighlights.map((signal) => signal.label)).toEqual([
'Rollout paused',
]);
expect(byID.get('pve:paused')?.fleetHighlights.map((signal) => signal.label)).toEqual([]);
expect(
byID.get('agent:remote-disabled')?.fleetHighlights.map((signal) => signal.label),
).toEqual([]);

View file

@ -762,9 +762,7 @@ const commandPolicySignal = (state: ConnectionFleetCommandPolicy): FleetGovernan
// reporting. Derived directly from connection state — not laundered
// through the fleet-signal pipeline — so the row builder owns the user
// story for "API works, agent doesn't" without a synthesized chip hack.
export const agentAttachmentProblem = (
agent: Connection,
): InfrastructureRowProblem | undefined => {
export const agentAttachmentProblem = (agent: Connection): InfrastructureRowProblem | undefined => {
switch (agent.state) {
case 'stale': {
const ago = lastActivityTextFromLastSeen(agent.lastSeen);
@ -800,21 +798,45 @@ export const agentAttachmentProblem = (
}
};
// Connections that run a Pulse Agent / collector binary. Agent-binary and
// managed-config governance (version drift, config drift, staged rollout,
// binary updates, remote command policy) only describes these — a pull-based
// API source (PVE, PBS, PMG, vSphere, TrueNAS, availability probe) has no
// agent, so the backend's echoed rollout/config state must not be surfaced as
// the row's problem. Without this gate an unreachable PBS reads "Rollout
// blocked" instead of plainly reflecting that it is unreachable.
const AGENT_FLEET_CONNECTION_TYPES: ReadonlySet<ConnectionType> = new Set([
'agent',
'docker',
'kubernetes',
]);
const connectionRunsAgentFleet = (connection: Connection): boolean =>
AGENT_FLEET_CONNECTION_TYPES.has(connection.type);
export const fleetGovernanceSignalsForConnection = (
connection: Connection,
): FleetGovernanceSignal[] => {
const fleet = fleetGovernanceForConnection(connection);
return [
const runsAgentFleet = connectionRunsAgentFleet(connection);
const signals: FleetGovernanceSignal[] = [
enrollmentSignal(fleet.enrollmentState, connection),
livenessSignal(fleet.livenessState),
credentialHealthSignal(credentialHealthFromFleet(fleet)),
configDriftSignal(configDriftFromFleet(fleet)),
rolloutSignal(rolloutFromFleet(fleet)),
versionSignal(fleet.versionDrift),
updateSignal(fleet.updateStatus),
adapterSignal(fleet.adapterHealth),
commandPolicySignal(commandPolicyFromFleet(fleet)),
];
if (runsAgentFleet) {
signals.push(
configDriftSignal(configDriftFromFleet(fleet)),
rolloutSignal(rolloutFromFleet(fleet)),
versionSignal(fleet.versionDrift),
updateSignal(fleet.updateStatus),
);
}
signals.push(adapterSignal(fleet.adapterHealth));
if (runsAgentFleet) {
signals.push(commandPolicySignal(commandPolicyFromFleet(fleet)));
}
return signals;
};
export const visibleFleetGovernanceSignals = (