diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 5e71f3143..d3dce965f 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -180,14 +180,18 @@ cannot prove agent-token continuity. Stale-agent platform notices and the Infrastructure `agentUpdates` workspace route are part of that same lifecycle contract. Platform pages may link to the central Infrastructure update dialog with a scoped list of affected agent -connection IDs, but command rendering stays lifecycle-owned in Settings. For -Unix-like agents that Pulse already sees, the copied stale-agent update command -must use `scripts/install.sh --update` and recover URL, token, identity, custom -CA, and insecure transport from installer-owned saved state instead of asking -the operator to mint a fresh install token or exposing agent IDs in the copied -command. Windows stale-agent update commands remain on the existing -token-gated install transport until the Windows installer owns an equivalent -saved-state update mode. +connection IDs, but command rendering stays lifecycle-owned in Settings. +`currentAgentTargetVersion()` is the canonical agent update target and must be +projected to browser clients as `/api/version.agentUpdateTargetVersion`; +platform pages and Settings must not compare agent binaries against the app +build `version` when the server would tell agents `dev`. For Unix-like agents +that Pulse already sees, the copied stale-agent update command must use +`scripts/install.sh --update` and recover URL, token, identity, custom CA, and +insecure transport from installer-owned saved state instead of asking the +operator to mint a fresh install token or exposing agent IDs in the copied +command. Windows stale-agent update commands remain on the existing token-gated +install transport until the Windows installer owns an equivalent saved-state +update mode. Agent lifecycle and fleet-operation surfaces may consume `POST /api/actions/plan` for resource capability planning, but the action plan diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 33595d0ef..4e1f470b3 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -120,8 +120,12 @@ update commands opened from platform notices must not serialize API tokens, agent IDs, or hostnames into Unix shell payloads. Those commands route through the `agentUpdates` settings query for scoped row selection and then call the installer-owned `scripts/install.sh --update` saved-state path on the target -host. Windows remains on the existing token-gated PowerShell payload until its -installer owns the same saved-state update contract. +host. Stale-agent UI must consume `/api/version`'s +`agentUpdateTargetVersion`, not the app build `version`, so development/source +builds that intentionally report `dev` to agents do not surface update nags +when no real agent update target exists. Windows remains on the existing +token-gated PowerShell payload until its installer owns the same saved-state +update contract. Summary-chart response caching is a shared API boundary: `internal/api/router.go` may serve a short cached JSON payload for repeated diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 120617dcd..a0604c56c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -127,6 +127,11 @@ TLS floor in the dynamic config. ## Shared Boundaries 1. `frontend-modern/src/api/updates.ts` shared with `api-contracts`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary. + The version payload consumed by this client must preserve the distinction + between the running app build `version` and the deployable + `agentUpdateTargetVersion`; update/install surfaces may display the app + build, but agent update prompts must only use the agent target when the + backend exposes one. 2. `internal/api/updates.go` shared with `api-contracts`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary. 3. `internal/cloudcp/docker/labels.go` shared with `cloud-paid`: hosted tenant Docker labels are both a Pulse Cloud runtime contract boundary and a deployment-installability rollout boundary. 4. `internal/cloudcp/docker/manager.go` shared with `cloud-paid`: hosted tenant container management is both a Pulse Cloud runtime contract boundary and a deployment-installability rollout boundary. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b544f9db1..d77977719 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -233,9 +233,12 @@ surface a compact stale-agent cue when their row model carries Pulse agent identity and version evidence, but the CTA must route to the canonical Infrastructure settings update-command surface with scoped agent IDs instead of duplicating installer command assembly, tokens, or lifecycle copy in each -platform page. Agentless API-only platforms such as vSphere must not grow this -notice unless a concrete guest or monitored system row actually carries a Pulse -agent identity. +platform page. These notices must compare against the API-owned +`agentUpdateTargetVersion` rather than the app build version, so development +builds can show their dirty server version without implying agents can or +should update toward that build. Agentless API-only platforms such as vSphere +must not grow this notice unless a concrete guest or monitored system row +actually carries a Pulse agent identity. Kubernetes policy inventory follows that same primitive boundary while the unified-resource owner supplies NetworkPolicy policy type and rule-count columns, PodDisruptionBudget budget and observed health columns, ResourceQuota diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 92bfa0889..d3cba8517 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -141,7 +141,6 @@ regression protection. 15. `frontend-modern/src/routing/routePreload.ts` shared with `frontend-primitives`: the app-shell route preload registry is both a canonical frontend shell boundary and an authenticated hot-path performance boundary. 16. `frontend-modern/src/useAppRuntimeState.ts` shared with `cloud-paid`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary. 17. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. - ## Extension Points 1. Add performance budgets through SLO or contract tests @@ -177,6 +176,10 @@ regression protection. process version once, but they must not add per-request release lookups, filesystem walks, or other heavy work just to compute whether an attached agent is current. + The `/api/version.agentUpdateTargetVersion` projection follows that same + rule: it may mirror the cached deployable agent target exposed to agents, + but it must not inspect attached agents, download artifacts, or resolve + release metadata on each request. Update-readiness planning follows the same bounded-router rule. The router may wire cached config and monitor host snapshots into the updates handler, but it must not add release lookups, broad persistence scans, or live agent diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index d57cff2c4..9b0282a10 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -121,13 +121,17 @@ controls as normal product settings. storing a brand setting never becomes a free branding bypass. 16. `internal/cloudcp/auth/magiclink.go` shared with `cloud-paid`: control-plane magic-link HMAC handling is both a Pulse Cloud account-access boundary and a security/privacy token-secrecy boundary. 17. `internal/cloudcp/auth/magiclink_store.go` shared with `cloud-paid`: control-plane magic-link persistence is both a Pulse Cloud account-access boundary and a security/privacy storage-hardening boundary. - ## Extension Points 1. Change privacy disclosures, usage-data vocabulary, or outbound-data guarantees through `docs/PRIVACY.md` and `internal/telemetry/telemetry.go` together. 2. Change security policy, hardening guidance, or supported auth boundaries through `SECURITY.md`. 3. Change telemetry/privacy settings state handling through `frontend-modern/src/components/Settings/useSystemSettingsState.ts`. 4. Change security/auth/token transport behavior through the shared `frontend-modern/src/api/security.ts`, `frontend-modern/src/components/Settings/APITokenManager.tsx`, `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`, `internal/api/security.go`, `internal/api/security_tokens.go`, and `internal/api/system_settings.go` boundary. + Release metadata surfaced through `/api/version` remains outside token, + auth, and privacy state. Adding or changing `agentUpdateTargetVersion` + must stay limited to non-secret deployable release identity and must not + expose agent inventory, scoped update selections, or command authorization + state. CSRF token-store behavior in `internal/api/csrf_store.go` is part of that shared browser-auth trust boundary: parallel stale-token mutations may receive distinct bounded replacement tokens for one session, but explicit diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index ccb4ca0c7..5049e35fa 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -77,7 +77,9 @@ plumbing even though `ProxmoxPageSurface` is a storage/recovery canonical file. Those notices may link an operator to scoped agent update commands for agent-contributed node detail and command support, but they must not be interpreted as backup visibility, recovery readiness, restore capability, or a -storage/recovery-owned command path. +storage/recovery-owned command path. Suppressing those notices when the API has +no deployable agent update target is likewise lifecycle/frontend behavior and +does not change Proxmox backup or recovery evidence. Tenant report branding settings are adjacent tenant-local configuration, not a storage or recovery product state. `reportBranding` persisted in a tenant runtime's `system.json` should be preserved by the existing tenant data diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index cbef81e5c..16c7932e4 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -488,6 +488,9 @@ AI-only summary payloads, or page-local heuristics. route to the affected agents. That scoped lifecycle handoff must not become a new resource-action authority, a page-local command runner, or a substitute for the `/api/connections` fleet command-policy truth described above. + Resource consumers must also use the API-owned agent update target when + comparing resource-carried agent versions; the running app build version is + not a resource freshness contract. `internal/unifiedresources/top_level_systems.go` Explicit linked-host correlation is canonical here: when Kubernetes node ingest has a resolved backing host agent, the registry must merge that node diff --git a/frontend-modern/src/api/__tests__/updates.test.ts b/frontend-modern/src/api/__tests__/updates.test.ts index d4b66be88..2f9a7135f 100644 --- a/frontend-modern/src/api/__tests__/updates.test.ts +++ b/frontend-modern/src/api/__tests__/updates.test.ts @@ -15,6 +15,23 @@ describe('UpdatesAPI', () => { apiFetchJSONMock.mockReset(); }); + it('preserves the app version and agent update target from version payloads', async () => { + const response = { + version: '6.0.0-rc.6+git.174.g259476907.dirty', + build: 'development', + runtime: 'go', + isDocker: false, + isSourceBuild: false, + isDevelopment: true, + deploymentType: 'development', + agentUpdateTargetVersion: '6.0.0-rc.6', + }; + apiFetchJSONMock.mockResolvedValueOnce(response as any); + + await expect(UpdatesAPI.getVersion()).resolves.toEqual(response); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/version'); + }); + it('encodes optional update-check channel safely', async () => { apiFetchJSONMock.mockResolvedValueOnce({ available: false } as any); await UpdatesAPI.checkForUpdates('rc'); diff --git a/frontend-modern/src/api/updates.ts b/frontend-modern/src/api/updates.ts index 30e2c5227..a1094c1dd 100644 --- a/frontend-modern/src/api/updates.ts +++ b/frontend-modern/src/api/updates.ts @@ -30,6 +30,7 @@ export interface VersionInfo { isSourceBuild: boolean; isDevelopment: boolean; deploymentType?: string; + agentUpdateTargetVersion?: string; } export type UpdateReadinessStatus = 'ready' | 'attention' | 'blocked'; diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index d5eac0445..d7bb53680 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -195,7 +195,7 @@ const InfrastructureWorkspaceContent: Component = const agentUpdateTargets = createMemo(() => collectInfrastructureAgentUpdateTargets( rows(), - updateStore.versionInfo()?.version, + updateStore.versionInfo()?.agentUpdateTargetVersion, agentUpdateScope(), ), ); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 8ee3150e8..e5edb78cc 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -435,6 +435,62 @@ describe('InfrastructureWorkspace', () => { }); }); + it('does not invent scoped update commands when the backend publishes no agent target', async () => { + routeState.search = '?agentUpdates=1&agents=agent%3Aagent-zeus'; + const primaryConnection = connectionFixture(); + const attachedAgent = connectionFixture({ + id: 'agent:agent-zeus', + type: 'agent', + name: 'zeus-agent', + address: 'zeus.lab', + surfaces: ['host'], + scope: { host: true }, + source: 'agent', + agentVersion: '5.1.34', + agentUpdateAvailable: false, + agentIdentity: { + hostname: 'zeus', + platform: 'linux', + }, + capabilities: { supportsPause: false, supportsScope: false, supportsTest: false }, + }); + connectionState.connections = [primaryConnection, attachedAgent]; + connectionState.rows = [ + { + id: primaryConnection.id, + ownerType: 'pve', + name: 'homelab', + subtitle: 'via platform API and Pulse Agent', + source: 'both', + host: primaryConnection.address, + coverageLabels: ['VMs', 'Host telemetry'], + statusLabel: 'Active', + statusClassName: 'bg-green-100 text-green-800', + agentUpdateCount: 0, + lastActivityText: '1m ago', + ...emptyFleetRow, + enabled: true, + canEdit: true, + canPause: true, + canRemove: true, + isAgent: false, + isCluster: false, + attachedConnections: [attachedAgent], + members: [], + connection: primaryConnection, + }, + ]; + + renderWorkspace(); + + const dialog = await screen.findByRole('dialog'); + expect( + within(dialog).getByText('Pulse does not currently see any agents behind the target version.'), + ).toBeInTheDocument(); + expect(within(dialog).queryByText('zeus')).not.toBeInTheDocument(); + expect(within(dialog).queryByText(/upgrade agent:agent-zeus/)).not.toBeInTheDocument(); + }); + it('keeps source groups in the catalog order instead of count order', async () => { const pveConnection = connectionFixture({ id: 'pve:zeus', diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.test.ts index 993d6e48c..46cc2d60b 100644 --- a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.test.ts @@ -124,7 +124,7 @@ describe('infrastructure agent update commands model', () => { }); }); - it('falls back to the app build version when the connections ledger has no target version', () => { + it('uses the explicit agent update target when the connection has no target version', () => { const staleAgent = connection({ id: 'agent:agent-delly', type: 'agent', @@ -144,7 +144,7 @@ describe('infrastructure agent update commands model', () => { attachedConnections: [staleAgent], }), ], - '6.0.0-rc.6+git.172.g2c360f779.dirty', + 'v6.0.0-rc.6', ); expect(targets).toHaveLength(1); @@ -155,6 +155,29 @@ describe('infrastructure agent update commands model', () => { }); }); + it('does not infer stale targets without an agent update target', () => { + const staleAgent = connection({ + id: 'agent:agent-delly', + type: 'agent', + name: 'delly', + address: 'delly', + surfaces: ['host'], + scope: { host: true }, + source: 'agent', + agentVersion: 'v6.0.0-rc.5', + agentUpdateAvailable: false, + capabilities: { supportsPause: false, supportsScope: false, supportsTest: false }, + }); + + const targets = collectInfrastructureAgentUpdateTargets([ + row({ + attachedConnections: [staleAgent], + }), + ]); + + expect(targets).toEqual([]); + }); + it('filters to scoped agent IDs when a platform notice links to specific hosts', () => { const staleDelly = connection({ id: 'agent:agent-delly', diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index 892b37f05..3105f0338 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -64,14 +64,17 @@ export function DockerPageSurface() { const activeTab = createMemo(() => tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview', ); + const agentUpdateTargetVersion = createMemo( + () => updateStore.versionInfo()?.agentUpdateTargetVersion, + ); const outdatedAgentHosts = createMemo(() => - collectOutdatedAgentHosts(model().hosts, updateStore.versionInfo()?.version), + collectOutdatedAgentHosts(model().hosts, agentUpdateTargetVersion()), ); const outdatedAgentUpdatePath = createMemo(() => buildInfrastructureAgentUpdatesPath(outdatedAgentHosts().map((host) => host.agentId)), ); const serverVersionDisplay = createMemo(() => - formatAgentVersionDisplay(updateStore.versionInfo()?.version), + formatAgentVersionDisplay(agentUpdateTargetVersion()), ); return ( diff --git a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx index 1f2aa6b5b..36ab1aa0e 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx @@ -69,7 +69,10 @@ vi.mock('@/hooks/useUnifiedResources', () => ({ vi.mock('@/stores/updates', () => ({ updateStore: { - versionInfo: () => ({ version: '6.0.0-rc.6' }), + versionInfo: () => ({ + version: '6.0.0-rc.6', + agentUpdateTargetVersion: '6.0.0-rc.6', + }), }, })); diff --git a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx index a4a9acec0..ac2b7a27c 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx @@ -69,14 +69,17 @@ export function KubernetesPageSurface() { tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview', ); const controllerResources = createMemo(() => getKubernetesControllerResources(model())); + const agentUpdateTargetVersion = createMemo( + () => updateStore.versionInfo()?.agentUpdateTargetVersion, + ); const outdatedAgentHosts = createMemo(() => - collectOutdatedAgentHosts(model().nodes, updateStore.versionInfo()?.version), + collectOutdatedAgentHosts(model().nodes, agentUpdateTargetVersion()), ); const outdatedAgentUpdatePath = createMemo(() => buildInfrastructureAgentUpdatesPath(outdatedAgentHosts().map((host) => host.agentId)), ); const serverVersionDisplay = createMemo(() => - formatAgentVersionDisplay(updateStore.versionInfo()?.version), + formatAgentVersionDisplay(agentUpdateTargetVersion()), ); return ( diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx index 917e6c1d2..d8c30dfbd 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx @@ -215,7 +215,10 @@ describe('KubernetesPageSurface contract', () => { }); it('surfaces stale agent-backed Kubernetes nodes', () => { - mockVersionInfo.mockReturnValue({ version: 'v6.0.0-rc.6' }); + mockVersionInfo.mockReturnValue({ + version: 'v6.0.0-rc.6', + agentUpdateTargetVersion: 'v6.0.0-rc.6', + }); setResources([ makeResource({ id: 'agent:k8s-node-1', diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index d1d42fb9c..e80756ebd 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -85,14 +85,17 @@ export function ProxmoxPageSurface() { if (!segment || !VALID_TABS.has(segment)) return 'overview'; return visibleTabIds().has(segment) ? segment : 'overview'; }); + const agentUpdateTargetVersion = createMemo( + () => updateStore.versionInfo()?.agentUpdateTargetVersion, + ); const outdatedAgentHosts = createMemo(() => - collectOutdatedAgentHosts(model().pveNodes, updateStore.versionInfo()?.version), + collectOutdatedAgentHosts(model().pveNodes, agentUpdateTargetVersion()), ); const outdatedAgentUpdatePath = createMemo(() => buildInfrastructureAgentUpdatesPath(outdatedAgentHosts().map((host) => host.agentId)), ); const serverVersionDisplay = createMemo(() => - formatAgentVersionDisplay(updateStore.versionInfo()?.version), + formatAgentVersionDisplay(agentUpdateTargetVersion()), ); // The hosts table at the top and the embedded WorkloadsSurface below share diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index f74dffb47..81e61a85f 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -123,7 +123,10 @@ describe('ProxmoxPageSurface contract', () => { }); it('surfaces stale agent-backed Proxmox nodes', () => { - mockVersionInfo.mockReturnValue({ version: 'v6.0.0-rc.6' }); + mockVersionInfo.mockReturnValue({ + version: 'v6.0.0-rc.6', + agentUpdateTargetVersion: 'v6.0.0-rc.6', + }); setResources([ makeResource({ id: 'agent:delly', @@ -149,4 +152,25 @@ describe('ProxmoxPageSurface contract', () => { '/settings/infrastructure?agentUpdates=1&agents=agent%3Aagent-delly', ); }); + + it('does not surface stale-agent notices for development builds without an agent target', () => { + mockVersionInfo.mockReturnValue({ + version: '6.0.0-rc.6+git.172.g2c360f779.dirty', + isDevelopment: true, + }); + setResources([ + makeResource({ + id: 'agent:delly', + name: 'delly', + displayName: 'delly', + type: 'agent', + proxmox: { nodeName: 'delly', clusterName: 'homelab' }, + agent: { agentId: 'agent-delly', agentVersion: 'v5.1.34' }, + }), + ]); + + render(() => ); + + expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts index 936728733..aaa8bb82f 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts @@ -201,6 +201,7 @@ describe('proxmoxBackupRecoveryModel', () => { expect(model.coverageRows).toHaveLength(1); expect(model.coverageRows[0].posture).toBe('uncovered'); + expect(model.coverageSummary.uncovered).toBe(1); expect(model.coverageRows[0].pbsCount).toBe(0); expect(model.coverageRows[0].archiveCount).toBe(0); expect(model.coverageRows[0].snapshotCount).toBe(0); diff --git a/frontend-modern/src/features/standalone/StandalonePageSurface.tsx b/frontend-modern/src/features/standalone/StandalonePageSurface.tsx index c72d545b5..60d2eba90 100644 --- a/frontend-modern/src/features/standalone/StandalonePageSurface.tsx +++ b/frontend-modern/src/features/standalone/StandalonePageSurface.tsx @@ -69,14 +69,17 @@ export function StandalonePageSurface() { model().machines.length === 0 && model().availabilityChecks.length === 0, ); + const agentUpdateTargetVersion = createMemo( + () => updateStore.versionInfo()?.agentUpdateTargetVersion, + ); const outdatedAgentHosts = createMemo(() => - collectOutdatedAgentHosts(model().machines, updateStore.versionInfo()?.version), + collectOutdatedAgentHosts(model().machines, agentUpdateTargetVersion()), ); const outdatedAgentUpdatePath = createMemo(() => buildInfrastructureAgentUpdatesPath(outdatedAgentHosts().map((host) => host.agentId)), ); const serverVersionDisplay = createMemo(() => - formatAgentVersionDisplay(updateStore.versionInfo()?.version), + formatAgentVersionDisplay(agentUpdateTargetVersion()), ); createEffect(() => { @@ -200,7 +203,7 @@ export function StandalonePageSurface() { emptyIcon={machineIcon()} emptyTitle="No machines" emptyDescription="Install Pulse Agent on Linux, macOS, Windows, or Unraid systems for full CPU, memory, disk, and network telemetry." - targetAgentVersion={updateStore.versionInfo()?.version} + targetAgentVersion={agentUpdateTargetVersion()} /> diff --git a/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx b/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx index c6bb5ef1c..dbc57b3f8 100644 --- a/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx +++ b/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx @@ -137,7 +137,10 @@ describe('StandalonePageSurface', () => { }); it('surfaces stale Pulse Agent binaries on the Machines page', () => { - mocks.versionInfo.mockReturnValue({ version: 'v6.0.0-rc.6' }); + mocks.versionInfo.mockReturnValue({ + version: 'v6.0.0-rc.6', + agentUpdateTargetVersion: 'v6.0.0-rc.6', + }); mocks.useUnifiedResources.mockReturnValue({ resources: () => [ resource({ diff --git a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx index f553bda23..37e9bf001 100644 --- a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx +++ b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx @@ -77,14 +77,17 @@ export function TrueNASPageSurface() { const activeTab = createMemo(() => tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview', ); + const agentUpdateTargetVersion = createMemo( + () => updateStore.versionInfo()?.agentUpdateTargetVersion, + ); const outdatedAgentHosts = createMemo(() => - collectOutdatedAgentHosts(model().systems, updateStore.versionInfo()?.version), + collectOutdatedAgentHosts(model().systems, agentUpdateTargetVersion()), ); const outdatedAgentUpdatePath = createMemo(() => buildInfrastructureAgentUpdatesPath(outdatedAgentHosts().map((host) => host.agentId)), ); const serverVersionDisplay = createMemo(() => - formatAgentVersionDisplay(updateStore.versionInfo()?.version), + formatAgentVersionDisplay(agentUpdateTargetVersion()), ); return ( diff --git a/frontend-modern/src/features/truenas/__tests__/TrueNASPageSurface.contract.test.tsx b/frontend-modern/src/features/truenas/__tests__/TrueNASPageSurface.contract.test.tsx index fcd7dc94c..d3f1fe991 100644 --- a/frontend-modern/src/features/truenas/__tests__/TrueNASPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/truenas/__tests__/TrueNASPageSurface.contract.test.tsx @@ -134,7 +134,10 @@ describe('TrueNASPageSurface contract', () => { }); it('surfaces stale agent-backed TrueNAS systems', () => { - mockVersionInfo.mockReturnValue({ version: 'v6.0.0-rc.6' }); + mockVersionInfo.mockReturnValue({ + version: 'v6.0.0-rc.6', + agentUpdateTargetVersion: 'v6.0.0-rc.6', + }); setResources([ makeResource({ id: 'agent:truenas-scale', diff --git a/internal/api/router.go b/internal/api/router.go index f62199869..d76ee3182 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -5275,16 +5275,17 @@ func (r *Router) handleVersion(w http.ResponseWriter, req *http.Request) { // Convert to typed response response := VersionResponse{ - Version: versionInfo.Version, - BuildTime: versionInfo.Build, - Build: versionInfo.Build, - GoVersion: runtime.Version(), - Runtime: versionInfo.Runtime, - Channel: versionInfo.Channel, - IsDocker: versionInfo.IsDocker, - IsSourceBuild: versionInfo.IsSourceBuild, - IsDevelopment: versionInfo.IsDevelopment, - DeploymentType: versionInfo.DeploymentType, + Version: versionInfo.Version, + BuildTime: versionInfo.Build, + Build: versionInfo.Build, + GoVersion: runtime.Version(), + Runtime: versionInfo.Runtime, + Channel: versionInfo.Channel, + IsDocker: versionInfo.IsDocker, + IsSourceBuild: versionInfo.IsSourceBuild, + IsDevelopment: versionInfo.IsDevelopment, + DeploymentType: versionInfo.DeploymentType, + AgentUpdateTargetVersion: currentAgentTargetVersion(), } // Detect containerization (LXC/Docker) diff --git a/internal/api/router_version_tenant_metrics_test.go b/internal/api/router_version_tenant_metrics_test.go index f495b0561..14aff2e4f 100644 --- a/internal/api/router_version_tenant_metrics_test.go +++ b/internal/api/router_version_tenant_metrics_test.go @@ -87,6 +87,31 @@ func TestHandleVersion_Success(t *testing.T) { } } +func TestHandleVersion_IncludesReleaseAgentUpdateTarget(t *testing.T) { + oldBuildVersion := updates.BuildVersion + updates.BuildVersion = "6.0.0-rc.6" + t.Cleanup(func() { + updates.BuildVersion = oldBuildVersion + }) + + router := &Router{updateManager: updates.NewManager(&config.Config{})} + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + rec := httptest.NewRecorder() + + router.handleVersion(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload VersionResponse + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.AgentUpdateTargetVersion != "6.0.0-rc.6" { + t.Fatalf("AgentUpdateTargetVersion = %q, want 6.0.0-rc.6", payload.AgentUpdateTargetVersion) + } +} + func TestHandleMetricsHistory_MethodNotAllowed(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodPost, "/api/metrics/history", nil) diff --git a/internal/api/types.go b/internal/api/types.go index b9b4e1711..6e3177b40 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -25,20 +25,21 @@ func (r HealthResponse) NormalizeCollections() HealthResponse { // VersionResponse represents version information type VersionResponse struct { - Version string `json:"version"` - BuildTime string `json:"buildTime,omitempty"` - Build string `json:"build,omitempty"` - GoVersion string `json:"goVersion,omitempty"` - Runtime string `json:"runtime,omitempty"` - Channel string `json:"channel,omitempty"` - IsDocker bool `json:"isDocker"` - IsSourceBuild bool `json:"isSourceBuild"` - IsDevelopment bool `json:"isDevelopment"` - DeploymentType string `json:"deploymentType,omitempty"` - UpdateAvailable bool `json:"updateAvailable"` - LatestVersion string `json:"latestVersion,omitempty"` - Containerized bool `json:"containerized"` - ContainerID string `json:"containerId,omitempty"` + Version string `json:"version"` + BuildTime string `json:"buildTime,omitempty"` + Build string `json:"build,omitempty"` + GoVersion string `json:"goVersion,omitempty"` + Runtime string `json:"runtime,omitempty"` + Channel string `json:"channel,omitempty"` + IsDocker bool `json:"isDocker"` + IsSourceBuild bool `json:"isSourceBuild"` + IsDevelopment bool `json:"isDevelopment"` + DeploymentType string `json:"deploymentType,omitempty"` + AgentUpdateTargetVersion string `json:"agentUpdateTargetVersion,omitempty"` + UpdateAvailable bool `json:"updateAvailable"` + LatestVersion string `json:"latestVersion,omitempty"` + Containerized bool `json:"containerized"` + ContainerID string `json:"containerId,omitempty"` } // ChartResponse represents chart data diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index ba82b3983..2be95b405 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 360, + "line": 364, "heading_line": 113, } ],