Compute attached-agent problem directly, drop synthesized signal

The agent-offline detail line was being produced by synthesizing a fake
FleetGovernanceSignal with key 'agent-attachment' and laundering it
through visibleFleetGovernanceSignals so the chip filter would pick it
up. With the chip stack gone and the row exposing a single
InfrastructureRowProblem, the synthesis is dead weight — it pretended
to be server-emitted governance state when it was really derived from
the agent's connection state.

- Replace agentAttachmentSignal with agentAttachmentProblem, returning
  InfrastructureRowProblem directly. The row builder applies it ahead
  of the fleet-highlights problem, so an offline agent always wins over
  a downstream credential/config drift signal on the same row.
- Remove 'agent-attachment' from FleetGovernanceSignalKey now that no
  signal carries it.
- Simplify the setup-summary Live/Needs attention counters to read off
  the user-visible row state (statusLabel, problem) instead of
  re-deriving via signal predicates. The previous path could
  in principle disagree with what the row actually rendered.
- Drop rowFleetSignals, rowHasFleetTone, rowHasVisibleFleetTone — all
  now unused.
This commit is contained in:
rcourtman 2026-05-17 14:48:44 +01:00
parent 41349be7d8
commit a48a1acd8d
3 changed files with 36 additions and 67 deletions

View file

@ -26,7 +26,6 @@ import {
connectionAgentVersionPresentation,
infrastructureSourcePresentation,
surfaceLabel,
type FleetGovernanceSignal,
type InfrastructureSystemRow,
} from './connectionsTableModel';
import type { DiscoveredServer, DiscoveryScanStatus } from './infrastructureSettingsModel';
@ -185,24 +184,6 @@ 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);
const rowHasVisibleFleetTone = (
row: InfrastructureSystemRow,
predicate: (signal: FleetGovernanceSignal) => boolean,
): boolean =>
[...row.fleetHighlights, ...row.members.flatMap((member) => member.fleetHighlights)].some(
predicate,
);
const agentHostProfileRouteStep = (
row: InfrastructureSystemRow,
): InfrastructureSourcePickerRouteStep | null => {
@ -393,24 +374,19 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return null;
});
// Counters read directly off the row state the user sees: a row is live
// when its status is "Active" and needs attention when its derived
// problem (or any member's) is non-empty. Going via signal predicates
// duplicated the rollup logic and could disagree with what was rendered.
const liveFleetSystemCount = createMemo(
() =>
props
.rows()
.filter((row) =>
rowHasFleetTone(row, (signal) => signal.key === 'liveness' && signal.tone === 'ok'),
).length,
() => props.rows().filter((row) => row.statusLabel === 'Active').length,
);
const fleetAttentionSystemCount = createMemo(
() =>
props
.rows()
.filter((row) =>
rowHasVisibleFleetTone(
row,
(signal) => signal.tone === 'warning' || signal.tone === 'critical',
),
).length,
.filter((row) => Boolean(row.problem) || row.members.some((member) => member.problem))
.length,
);
const discoveryReadinessLabel = createMemo(() => {
if (props.discoveryScanStatus().scanning) return 'Scanning now';

View file

@ -246,8 +246,7 @@ export type FleetGovernanceSignalKey =
| 'credential-health'
| 'updates'
| 'remote-control'
| 'command-policy'
| 'agent-attachment';
| 'command-policy';
export type FleetGovernanceSignalTone = 'ok' | 'info' | 'warning' | 'critical' | 'muted';
@ -824,19 +823,18 @@ const commandPolicySignal = (state: ConnectionFleetCommandPolicy): FleetGovernan
}
};
// Builds a plain-English chip when an attached Pulse Agent isn't reporting,
// to be rendered alongside a primary connection that *is* healthy. Avoids
// the prior pattern of stacking server-side terms ("Adapter degraded",
// liveness "Stale") that didn't tell the user which collection path failed.
export const agentAttachmentSignal = (
// Builds the row-level problem line when an attached Pulse Agent isn't
// 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,
): FleetGovernanceSignal | null => {
): InfrastructureRowProblem | undefined => {
switch (agent.state) {
case 'stale': {
const ago = lastActivityTextFromLastSeen(agent.lastSeen);
const suffix = ago && ago !== 'No activity yet' && ago !== 'Unknown' ? ` · ${ago}` : '';
return {
key: 'agent-attachment',
label: `Agent offline${suffix}`,
detail:
agent.stateReason ||
@ -846,27 +844,24 @@ export const agentAttachmentSignal = (
}
case 'unreachable':
return {
key: 'agent-attachment',
label: 'Agent unreachable',
detail: agent.stateReason || 'Pulse cannot currently reach the agent on this host.',
tone: 'critical',
};
case 'unauthorized':
return {
key: 'agent-attachment',
label: 'Agent unauthorized',
detail: agent.stateReason || 'The Pulse Agent token is being rejected.',
tone: 'critical',
};
case 'pending':
return {
key: 'agent-attachment',
label: 'Agent pending first report',
detail: agent.stateReason || 'The Pulse Agent has not reported yet.',
tone: 'warning',
};
default:
return null;
return undefined;
}
};

View file

@ -10,7 +10,7 @@ import {
type ConnectionType,
} from '@/api/connections';
import {
agentAttachmentSignal,
agentAttachmentProblem,
connectionAgentEndpointDisplay,
connectionAgentIdentitySummary,
connectionLastActivityText,
@ -18,7 +18,7 @@ import {
lastActivityTextFromLastSeen,
primaryRowProblem,
surfaceLabel,
type FleetGovernanceSignal,
type InfrastructureRowProblem,
type InfrastructureSourceKind,
type InfrastructureSystemMemberRow,
type InfrastructureSystemRow,
@ -291,13 +291,14 @@ const buildMemberRow = (
const state = member.state;
const presentation = STATE_PRESENTATION[state] ?? STATE_PRESENTATION.pending;
const lastSeen = member.lastSeen ?? agentConnection?.lastSeen;
const attachmentSignal = agentConnection ? agentAttachmentSignal(agentConnection) : null;
const fleetSignals: FleetGovernanceSignal[] = [
...(attachmentSignal ? [attachmentSignal] : []),
...(agentConnection ? fleetGovernanceSignalsForConnection(agentConnection) : []),
];
const fleetSignals = agentConnection ? fleetGovernanceSignalsForConnection(agentConnection) : [];
const fleetHighlights = visibleFleetGovernanceSignals(fleetSignals);
// Attachment problems (agent offline/unreachable/etc.) are derived from
// connection state directly; everything else (credentials, config, rollout)
// still rolls up from the prioritised highlight list.
const problem =
(agentConnection && agentAttachmentProblem(agentConnection)) ??
primaryRowProblem(fleetHighlights);
return {
id: member.id,
name,
@ -311,7 +312,7 @@ const buildMemberRow = (
lastActivityText: lastActivityTextFromLastSeen(lastSeen),
fleetSignals,
fleetHighlights,
problem: primaryRowProblem(fleetHighlights),
problem,
primary: Boolean(member.primary),
agentConnection,
};
@ -373,12 +374,15 @@ const buildRow = (
connection.id === primaryConnection.id || !memberAgentConnectionIds.has(connection.id),
)
: componentConnections;
// For non-agent primaries (PVE, PBS, etc.), surface a clearly-named chip
// when an attached agent is not reporting. We avoid double-injecting the
// chip for cluster member agents — those are tracked on the member rows.
const attachmentSignals: FleetGovernanceSignal[] =
const fleetSignals = rowFleetSignalConnections.flatMap((connection) =>
fleetGovernanceSignalsForConnection(connection),
);
// For non-agent primaries (PVE, PBS, etc.), surface the first unhealthy
// attached-agent connection as the row's problem line. Cluster member
// agents are tracked on the member rows, not duplicated here.
const attachmentProblem: InfrastructureRowProblem | undefined =
primaryConnection.type === 'agent'
? []
? undefined
: rowFleetSignalConnections
.filter(
(connection) =>
@ -386,14 +390,8 @@ const buildRow = (
connection.type === 'agent' &&
!memberAgentConnectionIds.has(connection.id),
)
.map((connection) => agentAttachmentSignal(connection))
.filter((signal): signal is FleetGovernanceSignal => Boolean(signal));
const fleetSignals: FleetGovernanceSignal[] = [
...attachmentSignals,
...rowFleetSignalConnections.flatMap((connection) =>
fleetGovernanceSignalsForConnection(connection),
),
];
.map((connection) => agentAttachmentProblem(connection))
.find((problem): problem is InfrastructureRowProblem => Boolean(problem));
const fleetHighlights = visibleFleetGovernanceSignals(fleetSignals);
return {
@ -414,7 +412,7 @@ const buildRow = (
lastErrorMessage,
fleetSignals,
fleetHighlights,
problem: primaryRowProblem(fleetHighlights),
problem: attachmentProblem ?? primaryRowProblem(fleetHighlights),
enabled: primaryConnection.enabled,
canEdit: EDITABLE_CONNECTION_TYPES.includes(primaryConnection.type),
canPause: primaryConnection.capabilities.supportsPause,