mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Flag outdated agents inline on the Docker page
A platform page hides its detail tabs (Docker images/storage/networks/swarm) when the matching inventory is absent. An agent too old to report that inventory therefore makes the tabs silently disappear, indistinguishable from a host that genuinely has none. The page gave the user no breadcrumb to the real cause (a stale agent). Add a reusable, self-explaining inline notice that appears only when a host on the page runs a Pulse agent behind the server version, naming the host, its version, the target version, and what it is missing. It stays invisible in the healthy case, so the surface stays clean. - agentVersion.ts: semver-aware version parse/compare (tolerates v-prefix and +build metadata, ranks rc.N correctly) and collectOutdatedAgentHosts, which reads docker- or agent-meta agent versions and skips hosts it cannot compare. - PlatformOutdatedAgentNotice.tsx: shared platformPage notice component. - DockerPageSurface: compute outdated hosts from the model + server version and render the notice above the tab content. - vitest coverage for the comparison logic and the notice copy. Compares against the server's running version rather than the release-gated currentAgentTargetVersion(), so the signal also works on dev/rc builds.
This commit is contained in:
parent
d8f5519eed
commit
6e3e1a4e97
5 changed files with 367 additions and 0 deletions
|
|
@ -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<DockerPageTabId>(() =>
|
||||
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 (
|
||||
<div data-testid="docker-page" class="space-y-3">
|
||||
|
|
@ -95,6 +107,11 @@ export function DockerPageSurface() {
|
|||
/>
|
||||
}
|
||||
>
|
||||
<PlatformOutdatedAgentNotice
|
||||
hosts={outdatedAgentHosts()}
|
||||
targetVersion={serverVersionDisplay()}
|
||||
missingLabel="images, networks, storage, and Swarm details"
|
||||
/>
|
||||
<Show when={activeTab() === 'overview'}>
|
||||
<DockerOverview
|
||||
hosts={model().hosts}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { PlatformOutdatedAgentNotice } from './PlatformOutdatedAgentNotice';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('PlatformOutdatedAgentNotice', () => {
|
||||
it('renders nothing when no hosts are outdated', () => {
|
||||
render(() => (
|
||||
<PlatformOutdatedAgentNotice hosts={[]} targetVersion="v6.0.0-rc.6" missingLabel="images" />
|
||||
));
|
||||
expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains a single outdated host and what it is missing', () => {
|
||||
render(() => (
|
||||
<PlatformOutdatedAgentNotice
|
||||
hosts={[{ name: 'tower', version: 'v6.0.0-rc.5' }]}
|
||||
targetVersion="v6.0.0-rc.6"
|
||||
missingLabel="images, networks, and storage"
|
||||
/>
|
||||
));
|
||||
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(() => (
|
||||
<PlatformOutdatedAgentNotice
|
||||
hosts={[
|
||||
{ name: 'tower', version: 'v6.0.0-rc.5' },
|
||||
{ name: 'delly', version: 'v6.0.0-rc.5' },
|
||||
]}
|
||||
targetVersion="v6.0.0-rc.6"
|
||||
missingLabel="images, networks, and storage"
|
||||
/>
|
||||
));
|
||||
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.');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<Show when={count() > 0}>
|
||||
<div
|
||||
role="status"
|
||||
data-testid="platform-outdated-agent-notice"
|
||||
class="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800/60 dark:bg-amber-900/20 dark:text-amber-200"
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span>{message()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlatformOutdatedAgentNotice;
|
||||
115
frontend-modern/src/features/platformPage/agentVersion.test.ts
Normal file
115
frontend-modern/src/features/platformPage/agentVersion.test.ts
Normal file
|
|
@ -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>): 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([]);
|
||||
});
|
||||
});
|
||||
141
frontend-modern/src/features/platformPage/agentVersion.ts
Normal file
141
frontend-modern/src/features/platformPage/agentVersion.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue