diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index a9041412e..507d678d8 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -34,6 +34,12 @@ import { type DockerPageTabId, type DockerResourceStatusFilter, } from './dockerPageModel'; +import { + collectOutdatedAgentHosts, + formatAgentVersionDisplay, +} from '@/features/platformPage/agentVersion'; +import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice'; +import { updateStore } from '@/stores/updates'; const DOCKER_RESOURCE_QUERY = 'type=agent,docker-host,app-container,docker-service,docker-image,docker-volume,docker-network,docker-task,docker-swarm-node,docker-secret,docker-config'; @@ -57,6 +63,12 @@ export function DockerPageSurface() { const activeTab = createMemo(() => tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview', ); + const outdatedAgentHosts = createMemo(() => + collectOutdatedAgentHosts(model().hosts, updateStore.versionInfo()?.version), + ); + const serverVersionDisplay = createMemo(() => + formatAgentVersionDisplay(updateStore.versionInfo()?.version), + ); return (
@@ -95,6 +107,11 @@ export function DockerPageSurface() { /> } > + { + cleanup(); +}); + +describe('PlatformOutdatedAgentNotice', () => { + it('renders nothing when no hosts are outdated', () => { + render(() => ( + + )); + expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument(); + }); + + it('explains a single outdated host and what it is missing', () => { + render(() => ( + + )); + const notice = screen.getByTestId('platform-outdated-agent-notice'); + expect(notice).toHaveTextContent( + 'tower is running an older Pulse agent (v6.0.0-rc.5). Update it to v6.0.0-rc.6 to see images, networks, and storage for this host.', + ); + }); + + it('summarises multiple outdated hosts and lists them', () => { + render(() => ( + + )); + const notice = screen.getByTestId('platform-outdated-agent-notice'); + expect(notice).toHaveTextContent('2 hosts are running an older Pulse agent.'); + expect(notice).toHaveTextContent('Affected: tower, delly.'); + }); +}); diff --git a/frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx b/frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx new file mode 100644 index 000000000..18d7c0ced --- /dev/null +++ b/frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx @@ -0,0 +1,48 @@ +import { Show, createMemo } from 'solid-js'; +import { AlertTriangle } from 'lucide-solid'; +import type { OutdatedAgentHost } from './agentVersion'; + +type PlatformOutdatedAgentNoticeProps = { + // Hosts on this page whose agent is behind the server version. + hosts: OutdatedAgentHost[]; + // The version users should update to (the server's version). Optional: the + // notice still reads sensibly without it. + targetVersion?: string; + // What the stale agents cannot report, e.g. "images, networks, and storage". + // Phrased to slot into "to see {missingLabel} for ...". + missingLabel: string; +}; + +// Inline, self-explaining notice shown on a platform page when one or more of +// its hosts run an agent too old to report part of the page's inventory. It is +// rendered only when there is an actually-outdated host, so the page stays +// clean in the healthy case. This is the breadcrumb that distinguishes a +// genuinely-empty detail tab from one hidden by a stale agent. +export function PlatformOutdatedAgentNotice(props: PlatformOutdatedAgentNoticeProps) { + const count = createMemo(() => props.hosts.length); + const names = createMemo(() => props.hosts.map((host) => host.name).join(', ')); + + const message = createMemo(() => { + const target = props.targetVersion ? ` to ${props.targetVersion}` : ''; + if (count() === 1) { + const host = props.hosts[0]; + return `${host.name} is running an older Pulse agent (${host.version}). Update it${target} to see ${props.missingLabel} for this host.`; + } + return `${count()} hosts are running an older Pulse agent. Update them${target} to see ${props.missingLabel}. Affected: ${names()}.`; + }); + + return ( + 0}> +
+
+
+ ); +} + +export default PlatformOutdatedAgentNotice; diff --git a/frontend-modern/src/features/platformPage/agentVersion.test.ts b/frontend-modern/src/features/platformPage/agentVersion.test.ts new file mode 100644 index 000000000..b3c74ec0c --- /dev/null +++ b/frontend-modern/src/features/platformPage/agentVersion.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { + collectOutdatedAgentHosts, + compareAgentVersions, + formatAgentVersionDisplay, + hostAgentVersion, + parseAgentVersion, +} from './agentVersion'; + +describe('parseAgentVersion', () => { + it('parses a plain semver', () => { + expect(parseAgentVersion('6.0.0')).toEqual({ major: 6, minor: 0, patch: 0, prerelease: [] }); + }); + + it('tolerates a leading v and strips build metadata', () => { + expect(parseAgentVersion('v6.0.0-rc.6+git.151.gd8f5519ee')).toEqual({ + major: 6, + minor: 0, + patch: 0, + prerelease: ['rc', '6'], + }); + }); + + it('returns null for empty or non-semver input', () => { + expect(parseAgentVersion('')).toBeNull(); + expect(parseAgentVersion(undefined)).toBeNull(); + expect(parseAgentVersion('dev')).toBeNull(); + expect(parseAgentVersion('6.0')).toBeNull(); + }); +}); + +describe('compareAgentVersions', () => { + it('orders rc.5 before rc.6', () => { + expect(compareAgentVersions('v6.0.0-rc.5', 'v6.0.0-rc.6')).toBe(-1); + expect(compareAgentVersions('v6.0.0-rc.6', 'v6.0.0-rc.5')).toBe(1); + }); + + it('treats equal versions as equal regardless of build metadata or v prefix', () => { + expect(compareAgentVersions('6.0.0-rc.6', 'v6.0.0-rc.6+git.151.gd8f5519ee')).toBe(0); + }); + + it('ranks a stable release above a pre-release of the same core', () => { + expect(compareAgentVersions('6.0.0-rc.6', '6.0.0')).toBe(-1); + expect(compareAgentVersions('6.0.0', '6.0.0-rc.6')).toBe(1); + }); + + it('orders by major, then minor, then patch', () => { + expect(compareAgentVersions('6.0.0', '7.0.0')).toBe(-1); + expect(compareAgentVersions('6.1.0', '6.0.9')).toBe(1); + expect(compareAgentVersions('6.0.1', '6.0.2')).toBe(-1); + }); + + it('returns null when either side is unparseable', () => { + expect(compareAgentVersions('dev', '6.0.0')).toBeNull(); + expect(compareAgentVersions('6.0.0', undefined)).toBeNull(); + }); +}); + +describe('formatAgentVersionDisplay', () => { + it('normalises to a v-prefixed string without build metadata', () => { + expect(formatAgentVersionDisplay('6.0.0-rc.6+git.151.gd8f5519ee')).toBe('v6.0.0-rc.6'); + expect(formatAgentVersionDisplay('v6.0.0')).toBe('v6.0.0'); + }); + + it('returns an empty string for unparseable input', () => { + expect(formatAgentVersionDisplay('dev')).toBe(''); + }); +}); + +const host = (over: Partial): Resource => ({ id: 'x', name: 'host', type: 'docker-host', ...over }) as Resource; + +describe('hostAgentVersion', () => { + it('reads the docker meta version for a docker host', () => { + expect(hostAgentVersion(host({ docker: { agentVersion: 'v6.0.0-rc.5' } as Resource['docker'] }))).toBe( + 'v6.0.0-rc.5', + ); + }); + + it('falls back to the agent meta version for a plain agent host', () => { + expect(hostAgentVersion(host({ agent: { agentVersion: 'v6.0.0-rc.5' } as Resource['agent'] }))).toBe( + 'v6.0.0-rc.5', + ); + }); + + it('returns undefined when no version is reported', () => { + expect(hostAgentVersion(host({}))).toBeUndefined(); + }); +}); + +describe('collectOutdatedAgentHosts', () => { + const server = 'v6.0.0-rc.6+git.151.gd8f5519ee'; + + it('flags hosts strictly behind the server version', () => { + const hosts = [ + host({ name: 'tower', docker: { agentVersion: 'v6.0.0-rc.5' } as Resource['docker'] }), + host({ name: 'current', docker: { agentVersion: 'v6.0.0-rc.6' } as Resource['docker'] }), + host({ name: 'delly', agent: { agentVersion: 'v6.0.0-rc.5' } as Resource['agent'] }), + ]; + expect(collectOutdatedAgentHosts(hosts, server)).toEqual([ + { name: 'tower', version: 'v6.0.0-rc.5' }, + { name: 'delly', version: 'v6.0.0-rc.5' }, + ]); + }); + + it('does not flag hosts with no reported version', () => { + expect(collectOutdatedAgentHosts([host({ name: 'mystery' })], server)).toEqual([]); + }); + + it('returns nothing when the server version is unknown or unparseable', () => { + const hosts = [host({ name: 'tower', docker: { agentVersion: 'v6.0.0-rc.5' } as Resource['docker'] })]; + expect(collectOutdatedAgentHosts(hosts, '')).toEqual([]); + expect(collectOutdatedAgentHosts(hosts, 'dev')).toEqual([]); + }); +}); diff --git a/frontend-modern/src/features/platformPage/agentVersion.ts b/frontend-modern/src/features/platformPage/agentVersion.ts new file mode 100644 index 000000000..ad2f8b41f --- /dev/null +++ b/frontend-modern/src/features/platformPage/agentVersion.ts @@ -0,0 +1,141 @@ +import type { Resource } from '@/types/resource'; + +// Agent staleness helpers for platform pages. +// +// Platform pages gate their detail tabs (Docker images/networks/storage/swarm, +// and the equivalent on other runtimes) purely on whether the matching +// inventory is present in the resource snapshot. An agent that predates a given +// inventory feature simply never reports it, so those tabs silently disappear +// with no way for the user to tell "this host genuinely has none" from "this +// host's agent is too old to report it". These helpers let a page detect the +// second case by comparing each host's reported agent version against the +// server that is rendering the page. + +export type ParsedAgentVersion = { + major: number; + minor: number; + patch: number; + // Pre-release identifiers, e.g. ['rc', '5']. Empty means a stable release, + // which always outranks any pre-release of the same core version. + prerelease: string[]; +}; + +// Parse a semver-shaped version string. Tolerates a leading `v`/`V` and strips +// build metadata (`+git.151.g...`) which carries no precedence. Returns null +// for anything that is not at least `major.minor.patch`. +export function parseAgentVersion(raw?: string | null): ParsedAgentVersion | null { + if (!raw) return null; + let value = raw.trim(); + if (!value) return null; + + const buildIdx = value.indexOf('+'); + if (buildIdx >= 0) value = value.slice(0, buildIdx); + value = value.replace(/^[vV]/, ''); + + const dashIdx = value.indexOf('-'); + const core = dashIdx >= 0 ? value.slice(0, dashIdx) : value; + const pre = dashIdx >= 0 ? value.slice(dashIdx + 1) : ''; + + const parts = core.split('.'); + if (parts.length < 3) return null; + const nums = parts.slice(0, 3).map((part) => Number.parseInt(part, 10)); + if (nums.some((n) => Number.isNaN(n))) return null; + + return { + major: nums[0], + minor: nums[1], + patch: nums[2], + prerelease: pre ? pre.split('.') : [], + }; +} + +function isNumericIdentifier(value: string): boolean { + return /^\d+$/.test(value); +} + +// Compare pre-release identifier lists per semver ยง11.4. +function comparePrerelease(a: string[], b: string[]): number { + if (a.length === 0 && b.length === 0) return 0; + // A stable release (no pre-release) outranks a pre-release. + if (a.length === 0) return 1; + if (b.length === 0) return -1; + + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + // A larger set of fields outranks a smaller one when all preceding equal. + if (i >= a.length) return -1; + if (i >= b.length) return 1; + + const ai = a[i]; + const bi = b[i]; + const aNum = isNumericIdentifier(ai); + const bNum = isNumericIdentifier(bi); + + if (aNum && bNum) { + const diff = Number.parseInt(ai, 10) - Number.parseInt(bi, 10); + if (diff !== 0) return diff < 0 ? -1 : 1; + } else if (aNum) { + // Numeric identifiers always rank lower than non-numeric ones. + return -1; + } else if (bNum) { + return 1; + } else if (ai !== bi) { + return ai < bi ? -1 : 1; + } + } + return 0; +} + +// Returns -1 / 0 / 1 when both versions parse, or null when either does not. +export function compareAgentVersions(a?: string | null, b?: string | null): number | null { + const pa = parseAgentVersion(a); + const pb = parseAgentVersion(b); + if (!pa || !pb) return null; + if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1; + if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1; + if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1; + return comparePrerelease(pa.prerelease, pb.prerelease); +} + +// The agent version reported for a host. Docker hosts carry it on the docker +// meta; plain host agents carry it on the agent meta. +export function hostAgentVersion(host: Resource): string | undefined { + return host.docker?.agentVersion?.trim() || host.agent?.agentVersion?.trim() || undefined; +} + +// Normalise a version for display: strip build metadata, ensure a leading `v`. +export function formatAgentVersionDisplay(raw?: string | null): string { + const parsed = parseAgentVersion(raw); + if (!parsed) return ''; + const core = `${parsed.major}.${parsed.minor}.${parsed.patch}`; + const pre = parsed.prerelease.length ? `-${parsed.prerelease.join('.')}` : ''; + return `v${core}${pre}`; +} + +export type OutdatedAgentHost = { name: string; version: string }; + +// Hosts whose agent version is strictly behind the server version. Hosts with +// no reported version (or an unparseable one) are skipped rather than flagged, +// so we never raise a false alarm we cannot substantiate. Returns an empty list +// when the server version itself is unknown or unparseable (e.g. a build that +// does not report one), so a page never nags without a basis for comparison. +export function collectOutdatedAgentHosts( + hosts: Resource[], + serverVersion?: string | null, +): OutdatedAgentHost[] { + if (!parseAgentVersion(serverVersion)) return []; + + const outdated: OutdatedAgentHost[] = []; + for (const host of hosts) { + const version = hostAgentVersion(host); + if (!version) continue; + const cmp = compareAgentVersions(version, serverVersion); + if (cmp !== null && cmp < 0) { + outdated.push({ + name: host.name?.trim() || host.id || 'host', + version: formatAgentVersionDisplay(version), + }); + } + } + return outdated; +}