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 1c380ea961..e077067776 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2633,7 +2633,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,