From 74a195daaf75b1620630f8f69054e64b81d99939 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:35:39 +0100 Subject: [PATCH] Flag an outdated agent once on the Infrastructure page Every outdated Pulse Agent row showed an amber Agent update badge and, on a second line, Version behind, and the band counted each of them as a system that needs attention ("11 systems needs attention"), although an agent waiting for an update is maintenance rather than an incident. Version behind and Update available are now maintenance signals: the badge, which opens Agent Doctor, carries them, so they never become the row's problem line and do not count toward needs attention. The band says how many agent updates are available in a neutral tone instead. A failed update and auto-update switched off stay problems, since each needs a different action, and a row's problem is picked before the three-highlight cap so a second agent's failure cannot hide behind another agent's version lag. The band's counts now agree in number, and the Actions column is wide only when a row shows an install button beside Manage, so the Health cell keeps the badge and the last-seen time on one line. --- .../v6/internal/subsystems/agent-lifecycle.md | 14 ++++ .../v6/internal/subsystems/api-contracts.md | 5 ++ .../subsystems/frontend-primitives.md | 4 +- .../8743aba58b02bde2.json | 53 +++++++++++++++ .../Settings/InfrastructureSourceManager.tsx | 38 +++++++++-- .../InfrastructureSourceManager.test.tsx | 46 +++++++++++++ .../__tests__/connectionsTableModel.test.ts | 65 +++++++++++++++++++ .../__tests__/settingsArchitecture.test.ts | 8 ++- .../__tests__/useConnectionsLedger.test.ts | 2 + .../Settings/connectionsTableModel.ts | 24 +++++-- .../Settings/useConnectionsLedger.ts | 5 +- 11 files changed, 249 insertions(+), 15 deletions(-) create mode 100644 frontend-modern/browser-verification/8743aba58b02bde2.json diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e64451bebf..4891c82049 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -5127,6 +5127,20 @@ API and Pulse Agent`) from the shared ledger contract. The compact landing may render its API / Agent / API + Agent badge beside the system name, with the full phrase available through accessible metadata and the detail flow, instead of spending a separate table column on collection method. +An agent that is behind the Pulse Agent target or has an update waiting is +maintenance, not a problem: the row's `Agent update` badge says so once and +opens Agent Doctor, so `primaryRowProblem` skips signals marked `maintenance` +(Version behind, Update available) and the row adds no `Version behind` line +beneath itself. Those rows therefore do not count toward the band's `needs +attention` total, which counts them separately and neutrally as agent updates +available. A failed update and auto-update switched off remain row problems, +because each asks for a different action, and the Manage drawer still lists +every fleet signal. A row's problem is picked from all of its visible signals +before the three-highlight cap, and problems rank ahead of maintenance in that +list, so a second attached agent's failed update cannot fall behind another +agent's version lag. The Actions column is only as wide as an install button +beside Manage needs when a row in view shows one, so Health keeps its state, +the update badge and the last-seen time on one line. Source badge class selection may use semantic gray treatment for API-only rows and typed non-gray tones for agent, probe, or combined sources, but the source identity remains the API/Agent/Probe label and subtitle from the shared ledger diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e459897ac5..12452c53b1 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -7343,6 +7343,11 @@ agent-applied setting, `fleet.configDrift` must remain `not-applicable` and diagnostics, but must not count that passive state as source setup attention. Only a real managed desired config fingerprint can create pending rollout attention when an applied fingerprint is absent or mismatched. +Likewise `fleet.versionDrift: behind` and `fleet.updateStatus: +update-available` are maintenance, not source setup attention: +`useConnectionsLedger.ts` keeps them out of a row's problem and the band's +needs-attention count, which the row's agent-update count and badge carry +instead, while `updateStatus: failed` and `disabled` remain row problems. The `fleet.commandPolicy` object is the canonical desired/applied convergence contract for remote command enablement. It must carry the desired server policy, the applied agent-reported truth when available, the effective diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 1fbee03224..21e66618d5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2631,7 +2631,9 @@ Agent`), with the plain-language source phrase available through accessible always-on version column for Pulse Agent; exact version text belongs in the edit/detail surfaces, while the landing table only surfaces a compact warning badge when an attached or standalone agent actually has an update - available. That same table boundary must reuse the `System` cell for compact + available. That badge is the row's only version flag: the row adds no + `Version behind` problem line beneath it, and the source band counts those + rows as agent updates available rather than as needing attention. That same table boundary must reuse the `System` cell for compact standalone-agent identity such as `Unraid 7.1.0`; raw reported addresses belong in the governed Manage detail or an explicitly expanded cluster-member row, not an always-on diagnostics column. diff --git a/frontend-modern/browser-verification/8743aba58b02bde2.json b/frontend-modern/browser-verification/8743aba58b02bde2.json new file mode 100644 index 0000000000..29c1b5cf97 --- /dev/null +++ b/frontend-modern/browser-verification/8743aba58b02bde2.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "base_sha": "2218ab6c18cecde801a1a4e920071089032d46a8", + "verified_at": "2026-10-05T16:35:39Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx", + "frontend-modern/src/components/Settings/connectionsTableModel.ts", + "frontend-modern/src/components/Settings/useConnectionsLedger.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx": "0e631a2eaf7ba72e2a70e99ceca0639c82372b8700b831f48d5b93f9dce35ab1", + "frontend-modern/src/components/Settings/connectionsTableModel.ts": "1ee02ad9c25795e44f766d7d4dff615cd2323e3691c30e844a3d5dfa4973c64d", + "frontend-modern/src/components/Settings/useConnectionsLedger.ts": "4761b1a14a6af04225af07036ecf800334a12ab3b1aa418e400ddf4e2bb841ec" + }, + "routes": [ + "/settings/infrastructure" + ], + "viewports": [ + { + "width": 390, + "height": 1600 + }, + { + "width": 1100, + "height": 1000 + }, + { + "width": 1280, + "height": 1000 + }, + { + "width": 1440, + "height": 1100 + }, + { + "width": 1920, + "height": 1000 + } + ], + "states": [ + "Mock estate with 13 connected systems, 11 of them Pulse Agent hosts behind the agent target with an update available", + "Each outdated agent row shows Active, one Agent update badge (opens Agent Doctor) and its last-seen time on one line; main added a second italic Version behind line under every one of the 11 rows", + "The band reads 13 systems connected \u00b7 All active \u00b7 11 agent updates available in a neutral tone and Open Agent Doctor is a secondary button; main read 11 systems needs attention in amber with a primary button although every one was only version lag", + "Health column at 1280 / 1440 / 1920: 228 / 267 / 390px with no clipped cell (main 176 / 216 / 332px with 11 and 6 clipped cells, the last-seen time cut to 18s a...); no cell wraps", + "Phone card layout at 390: neutral band, no Version behind line on any card, 11 Agent update badges; below 1100 the page uses the card layout as before", + "Re-measured after rebasing onto 2218ab6c18 (shared Table row-click change from #2524): same widths, no clipped cells, phone layout unchanged" + ], + "interactions": [ + "Measured column widths and clipped cells on main (reverse-applied patch) and on the change at 1100, 1280, 1440 and 1920", + "Counted Version behind lines and Agent update badges in the live phone layout" + ] +} diff --git a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx index c6f8bdc6f3..cf9f66f608 100644 --- a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx @@ -502,6 +502,15 @@ export const InfrastructureSourceManager: Component !props.readOnly; + // Actions holds Manage (about 96px with padding) and, on a row missing agent + // coverage, an install button beside it. Reserving the wide column only when + // a row shows that button leaves Health room for its state, the Agent update + // badge and the last-seen time on one line. + const actionColumnShowsInstall = createMemo( + () => + Boolean(props.onAddSourceStep || props.onAddSource) && + infrastructureRows().some((row) => rowNeedsAgentCoverage(row)), + ); const lastDiscoveryResultText = createMemo(() => formatRelativeTimestamp(props.discoveryScanStatus().lastResultAt), ); @@ -543,6 +552,11 @@ export const InfrastructureSourceManager: Component Boolean(row.problem) || row.members.some((member) => member.problem), ).length, ); + // Outdated agents are maintenance rather than attention (see + // FleetGovernanceSignal.maintenance); the band still counts them once. + const agentUpdateTotal = createMemo(() => + infrastructureRows().reduce((total, row) => total + row.agentUpdateCount, 0), + ); const discoveryMonitorTitle = createMemo(() => { const status = props.discoveryScanStatus(); if (status.scanning) return 'Scanning'; @@ -815,7 +829,16 @@ export const InfrastructureSourceManager: Component - {formatCount(fleetAttentionSystemCount(), 'system')} needs attention + {formatCount(fleetAttentionSystemCount(), 'system')}{' '} + {fleetAttentionSystemCount() === 1 ? 'needs' : 'need'} attention + + + 0}> + + + {formatCount(agentUpdateTotal(), 'agent update')} available 0}> @@ -823,7 +846,8 @@ export const InfrastructureSourceManager: Component - {formatCount(uncoveredAgentTargetCount(), 'host')} has limited coverage + {formatCount(uncoveredAgentTargetCount(), 'host')}{' '} + {uncoveredAgentTargetCount() === 1 ? 'has' : 'have'} limited coverage @@ -897,17 +921,19 @@ export const InfrastructureSourceManager: Component - + System - + Coverage - + Health - + Actions diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx index 89b27335d3..16e8b825ae 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx @@ -6,7 +6,9 @@ import { agentConnectionIDsForInfrastructureRow, } from '../InfrastructureSourceManager'; import { + fleetGovernanceSignalsForConnection, primaryRowProblem, + visibleFleetGovernanceSignals, type FleetGovernanceSignal, type InfrastructureSystemMemberRow, type InfrastructureSystemRow, @@ -398,6 +400,50 @@ describe('InfrastructureSourceManager setup summary', () => { expect(onOpenAgentDoctor).toHaveBeenCalledWith(['agent:behind-host']); }); + it('flags an outdated agent once, as an update rather than as attention', () => { + const outdated = connectionFixture({ + id: 'agent:outdated-host', + name: 'outdated-host', + agentUpdateAvailable: true, + fleet: { + enrollmentState: 'enrolled', + livenessState: 'active', + versionDrift: 'behind', + adapterHealth: 'healthy', + configRollout: 'reported', + credentialStatus: 'verified', + updateStatus: 'update-available', + remoteControl: 'disabled', + }, + }); + const fleetSignals = fleetGovernanceSignalsForConnection(outdated); + const outdatedRow = row({ + connection: outdated, + agentUpdateCount: 1, + fleetSignals, + fleetHighlights: visibleFleetGovernanceSignals(fleetSignals), + problem: primaryRowProblem(visibleFleetGovernanceSignals(fleetSignals, Infinity)), + }); + + render(() => ( + [outdatedRow]} + discoveredNodes={() => []} + discoveryEnabled={false} + discoveryScanStatus={() => ({ scanning: false })} + readOnly={false} + onOpenAgentDoctor={vi.fn()} + /> + )); + + expect( + screen.getByRole('button', { name: 'Open Agent Doctor for outdated-host' }), + ).toHaveTextContent('Agent update'); + expect(screen.queryByText('Version behind')).toBeNull(); + expect(screen.queryByText(/needs? attention/i)).toBeNull(); + expect(screen.getByText('1 agent update available')).toBeInTheDocument(); + }); + it('still counts actionable member posture when the cluster parent is healthy', () => { render(() => ( { }); }); +describe('primaryRowProblem', () => { + const outdatedFleet = (updateStatus: 'update-available' | 'failed' | 'disabled') => + connectionFixture({ + agentUpdate: + updateStatus === 'failed' + ? { state: 'error', autoUpdate: true, lastError: 'signature verification failed' } + : undefined, + fleet: { + enrollmentState: 'enrolled', + livenessState: 'active', + versionDrift: 'behind', + adapterHealth: 'healthy', + configRollout: 'reported', + credentialStatus: 'verified', + updateStatus, + remoteControl: 'disabled', + }, + }); + + it('leaves an outdated agent to its Agent update badge instead of naming a problem', () => { + const highlights = visibleFleetGovernanceSignals( + fleetGovernanceSignalsForConnection(outdatedFleet('update-available')), + ); + // The Manage drawer keeps the structured signals. + expect(highlights.map((signal) => signal.label)).toEqual([ + 'Version behind', + 'Update available', + ]); + expect(primaryRowProblem(highlights)).toBeUndefined(); + }); + + it("finds a failed update on a second attached agent behind another agent's version lag", () => { + const signals = [ + ...fleetGovernanceSignalsForConnection(outdatedFleet('update-available')), + ...fleetGovernanceSignalsForConnection({ + ...outdatedFleet('failed'), + id: 'agent:host-2', + name: 'host-2', + }), + ]; + expect(primaryRowProblem(visibleFleetGovernanceSignals(signals, Infinity))).toMatchObject({ + label: 'Agent update failed', + tone: 'critical', + }); + // The capped highlight list leads with the problem, not the maintenance. + expect(visibleFleetGovernanceSignals(signals)[0]?.label).toBe('Agent update failed'); + }); + + it('still names a failed update or disabled auto-update, which need a different action', () => { + expect( + primaryRowProblem( + visibleFleetGovernanceSignals(fleetGovernanceSignalsForConnection(outdatedFleet('failed'))), + ), + ).toMatchObject({ label: 'Agent update failed', tone: 'critical' }); + expect( + primaryRowProblem( + visibleFleetGovernanceSignals( + fleetGovernanceSignalsForConnection(outdatedFleet('disabled')), + ), + ), + ).toMatchObject({ label: 'Auto-update off', tone: 'warning' }); + }); +}); + describe('fleetGovernanceSignalsForConnection connection-type gating', () => { it('omits agent-fleet governance signals for pull-based API sources', () => { const signals = fleetGovernanceSignalsForConnection( diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 5ed24fe4ae..768cf83f60 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1665,8 +1665,12 @@ describe('settings architecture guardrails', () => { expect(infrastructureSourceManagerSource).toContain('Connection posture'); expect(infrastructureSourceManagerSource).toContain("'system')} connected"); expect(infrastructureSourceManagerSource).toContain('All active'); - expect(infrastructureSourceManagerSource).toContain('needs attention'); - expect(infrastructureSourceManagerSource).toContain('has limited coverage'); + expect(infrastructureSourceManagerSource).toContain( + "{fleetAttentionSystemCount() === 1 ? 'needs' : 'need'} attention", + ); + expect(infrastructureSourceManagerSource).toContain( + "{uncoveredAgentTargetCount() === 1 ? 'has' : 'have'} limited coverage", + ); expect(infrastructureSourceManagerSource).toContain('setupConfidenceAction'); expect(infrastructureSourceManagerSource).not.toContain('Infrastructure coverage'); expect(infrastructureSourceManagerSource).not.toContain('Fleet governance'); diff --git a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts index b72056ef39..74821b59e1 100644 --- a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts @@ -552,6 +552,8 @@ describe('useConnectionsLedger', () => { 'Rollout pending', 'Version behind', ]); + // Version lag rides on the Agent update badge; the problem line names the drift. + expect(byID.get('agent:drifted')?.problem?.label).toBe('Config drift'); // 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 diff --git a/frontend-modern/src/components/Settings/connectionsTableModel.ts b/frontend-modern/src/components/Settings/connectionsTableModel.ts index 5c1c874426..7bbc204974 100644 --- a/frontend-modern/src/components/Settings/connectionsTableModel.ts +++ b/frontend-modern/src/components/Settings/connectionsTableModel.ts @@ -256,6 +256,11 @@ export interface FleetGovernanceSignal { label: string; detail: string; tone: FleetGovernanceSignalTone; + // An agent that is behind or has an update waiting is maintenance, not a + // problem: the row's Agent update badge already says so and opens Agent + // Doctor, so the row does not repeat it as its problem line or count it as + // needing attention. The Manage drawer still lists the signal. + maintenance?: boolean; } const DEFAULT_FLEET_GOVERNANCE: ConnectionFleetGovernance = { @@ -462,6 +467,7 @@ const versionSignal = (state: ConnectionFleetVersionDrift): FleetGovernanceSigna label: 'Version behind', detail: 'This agent is behind the current Pulse Agent target.', tone: 'warning', + maintenance: true, }; case 'current': return { @@ -686,6 +692,7 @@ const updateSignal = ( label: 'Update available', detail: 'A newer Pulse Agent binary is available for this system.', tone: 'warning', + maintenance: true, }; case 'checking': return { @@ -906,8 +913,12 @@ export const fleetGovernanceSignalsForConnection = ( return signals; }; +// The row shows at most three highlights. A row's problem is picked from the +// whole visible list (limit Infinity) so a critical signal from a second +// attached agent cannot fall behind the cap. export const visibleFleetGovernanceSignals = ( signals: readonly FleetGovernanceSignal[], + limit = 3, ): FleetGovernanceSignal[] => { const hasPassiveAgentConfigConfirmation = signals.some(isPassiveAgentConfigConfirmationSignal); const visibleSignals = signals.filter((signal) => { @@ -929,11 +940,14 @@ export const visibleFleetGovernanceSignals = ( if (signal.key === 'command-policy' && signal.tone === 'info') return false; return true; }); + // Problems lead; maintenance (an agent behind or with an update waiting) + // follows them, since the Agent update badge already carries it. const attention = visibleSignals.filter( - (signal) => signal.tone === 'critical' || signal.tone === 'warning', + (signal) => !signal.maintenance && (signal.tone === 'critical' || signal.tone === 'warning'), ); - const control = visibleSignals.filter((signal) => signal.tone === 'info'); - return [...attention, ...control].slice(0, 3); + const maintenance = visibleSignals.filter((signal) => signal.maintenance); + const control = visibleSignals.filter((signal) => !signal.maintenance && signal.tone === 'info'); + return [...attention, ...maintenance, ...control].slice(0, limit); }; const isPassiveAgentConfigConfirmationSignal = (signal: FleetGovernanceSignal): boolean => { @@ -1027,9 +1041,11 @@ export interface InfrastructureSystemRow { // Pick the single most important problem to surface in the row's status // column. Critical beats warning; otherwise first wins — the upstream // builder has already prioritised highlights (attention before info). +// Maintenance signals never become the problem (see FleetGovernanceSignal). export const primaryRowProblem = ( - signals: readonly FleetGovernanceSignal[], + allSignals: readonly FleetGovernanceSignal[], ): InfrastructureRowProblem | undefined => { + const signals = allSignals.filter((signal) => !signal.maintenance); const critical = signals.find((signal) => signal.tone === 'critical'); if (critical) { return { label: critical.label, detail: critical.detail, tone: 'critical' }; diff --git a/frontend-modern/src/components/Settings/useConnectionsLedger.ts b/frontend-modern/src/components/Settings/useConnectionsLedger.ts index 2ab8538b99..5a54535676 100644 --- a/frontend-modern/src/components/Settings/useConnectionsLedger.ts +++ b/frontend-modern/src/components/Settings/useConnectionsLedger.ts @@ -307,7 +307,7 @@ const buildMemberRow = ( // still rolls up from the prioritised highlight list. const problem = (agentConnection && agentAttachmentProblem(agentConnection)) ?? - primaryRowProblem(fleetHighlights); + primaryRowProblem(visibleFleetGovernanceSignals(fleetSignals, Infinity)); return { id: member.id, nodeIdentity: member.nodeIdentity?.trim() || undefined, @@ -423,7 +423,8 @@ const buildRow = ( lastErrorMessage, fleetSignals, fleetHighlights, - problem: attachmentProblem ?? primaryRowProblem(fleetHighlights), + problem: + attachmentProblem ?? primaryRowProblem(visibleFleetGovernanceSignals(fleetSignals, Infinity)), enabled: primaryConnection.enabled, canEdit: EDITABLE_CONNECTION_TYPES.includes(primaryConnection.type), canPause: primaryConnection.capabilities.supportsPause,