chore: gate self-update prompts on published registry images (#1519)

GitHub Releases appear before docker-publish.yml finishes pushing images.

Probe Docker Hub and GHCR manifests before advertising a version as available.

Sanitize registry probe debug logs for CodeQL log-injection.
This commit is contained in:
Anso
2026-06-29 16:29:56 -04:00
committed by GitHub
parent 89b9341568
commit 624b586887
6 changed files with 312 additions and 42 deletions
@@ -256,7 +256,7 @@ describe('forced-recheck throttle', () => {
.set('Authorization', adminAuth);
expect(first.status).toBe(200);
expect(first.body.rechecked).toBe(true);
expect(invalidateSpy).toHaveBeenCalledWith('latest-version');
expect(invalidateSpy).toHaveBeenCalledWith('latest-version-info');
invalidateSpy.mockClear();
+53 -17
View File
@@ -16,6 +16,7 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockExecAsync,
mockFetchLatestSenchoVersion,
mockGetLatestVersion,
mockGetLatestVersionInfo,
mockGetSenchoVersion,
} = vi.hoisted(() => ({
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
@@ -45,6 +46,7 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockExecAsync: vi.fn().mockResolvedValue({ stdout: '' }),
mockFetchLatestSenchoVersion: vi.fn().mockRejectedValue(new Error('not configured')),
mockGetLatestVersion: vi.fn().mockResolvedValue(null),
mockGetLatestVersionInfo: vi.fn().mockResolvedValue(null),
mockGetSenchoVersion: vi.fn().mockReturnValue(null),
}));
@@ -89,6 +91,7 @@ vi.mock('../services/FileSystemService', () => ({
vi.mock('../utils/version-check', () => ({
fetchLatestSenchoVersion: (...args: unknown[]) => mockFetchLatestSenchoVersion(...args),
getLatestVersion: (...args: unknown[]) => mockGetLatestVersion(...args),
getLatestVersionInfo: (...args: unknown[]) => mockGetLatestVersionInfo(...args),
}));
vi.mock('../services/CapabilityRegistry', async () => {
@@ -394,6 +397,7 @@ describe('MonitorService - host_alerts_enabled toggle', () => {
// Restore default mock implementations so version-check state does not
// leak into sibling describe blocks (F-11 suppression, version check).
mockGetLatestVersion.mockResolvedValue(null);
mockGetLatestVersionInfo.mockResolvedValue(null);
mockGetSenchoVersion.mockReturnValue(null);
});
@@ -471,13 +475,13 @@ describe('MonitorService - host_alerts_enabled toggle', () => {
it('still runs version check when disabled', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
const svc = MonitorService.getInstance();
(svc as any).lastVersionCheckAt = 0;
await (svc as any).evaluateGlobalSettings({ host_alerts_enabled: '0' });
expect(mockGetLatestVersion).toHaveBeenCalled();
expect(mockGetLatestVersionInfo).toHaveBeenCalled();
});
});
@@ -1082,7 +1086,7 @@ describe('MonitorService - Sencho version check', () => {
it('dispatches notification when newer version available', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
mockGetSystemState.mockReturnValue(null); // No previous notification
const svc = MonitorService.getInstance();
@@ -1098,7 +1102,7 @@ describe('MonitorService - Sencho version check', () => {
it('does not re-notify for the same version', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
// Running version < last notified, so self-heal does NOT clear the key.
mockGetSystemState.mockReturnValue('0.46.0');
@@ -1111,7 +1115,7 @@ describe('MonitorService - Sencho version check', () => {
it('handles version check failure gracefully', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue(null); // CacheService failed + no stale
mockGetLatestVersionInfo.mockResolvedValue(null); // CacheService failed + no stale
const svc = MonitorService.getInstance();
(svc as any).lastVersionCheckAt = 0;
@@ -1123,7 +1127,7 @@ describe('MonitorService - Sencho version check', () => {
it('respects the 6-hour cooldown interval', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
@@ -1131,14 +1135,14 @@ describe('MonitorService - Sencho version check', () => {
(svc as any).lastVersionCheckAt = Date.now() - 1 * 60 * 60 * 1000;
await (svc as any).evaluate();
// getLatestVersion should not have been called since we're within cooldown
expect(mockGetLatestVersion).not.toHaveBeenCalled();
// getLatestVersionInfo should not have been called since we're within cooldown
expect(mockGetLatestVersionInfo).not.toHaveBeenCalled();
});
it('skips version check when getSenchoVersion returns null', async () => {
// Simulates the production-Docker scenario that previously leaked "0.0.0"
mockGetSenchoVersion.mockReturnValue(null);
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
@@ -1148,14 +1152,14 @@ describe('MonitorService - Sencho version check', () => {
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0'));
expect(mockSetSystemState).not.toHaveBeenCalledWith('last_sencho_update_notified_version', expect.anything());
// Should not have even attempted the lookup.
expect(mockGetLatestVersion).not.toHaveBeenCalled();
expect(mockGetLatestVersionInfo).not.toHaveBeenCalled();
});
// ── Regression coverage for PR: cooldown leak + dedup self-heal ───────
it('does NOT advance cooldown when getLatestVersion returns null (retries next cycle)', async () => {
it('does NOT advance cooldown when getLatestVersionInfo returns null (retries next cycle)', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue(null);
mockGetLatestVersionInfo.mockResolvedValue(null);
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
@@ -1165,13 +1169,29 @@ describe('MonitorService - Sencho version check', () => {
await (svc as any).evaluate();
// Both evals should attempt the lookup since failures do not lock cooldown.
expect(mockGetLatestVersion).toHaveBeenCalledTimes(2);
expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(2);
expect((svc as any).lastVersionCheckAt).toBe(0);
});
it('does NOT advance cooldown while a GitHub release is pending registry publish', async () => {
mockGetSenchoVersion.mockReturnValue('0.93.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.93.0', publishPending: true });
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
(svc as any).lastVersionCheckAt = 0;
await (svc as any).evaluate();
await (svc as any).evaluate();
expect(mockGetLatestVersionInfo).toHaveBeenCalledTimes(2);
expect((svc as any).lastVersionCheckAt).toBe(0);
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('available'));
});
it('DOES advance cooldown on a successful lookup (prevents re-fetch inside window)', async () => {
mockGetSenchoVersion.mockReturnValue('0.45.0');
mockGetLatestVersion.mockResolvedValue('0.46.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.46.0', publishPending: false });
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
@@ -1182,10 +1202,10 @@ describe('MonitorService - Sencho version check', () => {
expect(firstCooldown).toBeGreaterThan(0);
// Second eval immediately after: cooldown gate should block it.
mockGetLatestVersion.mockClear();
mockGetLatestVersionInfo.mockClear();
await (svc as any).evaluate();
expect(mockGetLatestVersion).not.toHaveBeenCalled();
expect(mockGetLatestVersionInfo).not.toHaveBeenCalled();
// Exactly one dispatch across both evals.
const availabilityDispatches = mockDispatchAlert.mock.calls.filter(
(args: unknown[]) => typeof args[2] === 'string' && args[2].includes('available'),
@@ -1198,7 +1218,7 @@ describe('MonitorService - Sencho version check', () => {
// User has now upgraded to 0.46.0; a new release (0.47.0) just dropped.
const store = wireStatefulSystemState({ last_sencho_update_notified_version: '0.46.0' });
mockGetSenchoVersion.mockReturnValue('0.46.0');
mockGetLatestVersion.mockResolvedValue('0.47.0');
mockGetLatestVersionInfo.mockResolvedValue({ version: '0.47.0', publishPending: false });
const svc = MonitorService.getInstance();
(svc as any).lastVersionCheckAt = 0;
@@ -1207,6 +1227,22 @@ describe('MonitorService - Sencho version check', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.47.0'));
expect(store.last_sencho_update_notified_version).toBe('0.47.0');
});
it('notifies once the registry publish completes after a pending GitHub release', async () => {
mockGetSenchoVersion.mockReturnValue('0.93.0');
mockGetSystemState.mockReturnValue(null);
const svc = MonitorService.getInstance();
(svc as any).lastVersionCheckAt = 0;
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.93.0', publishPending: true });
await (svc as any).evaluate();
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.94.0'));
mockGetLatestVersionInfo.mockResolvedValueOnce({ version: '0.94.0', publishPending: false });
await (svc as any).evaluate();
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.94.0'));
});
});
// ── Per-container parallel fan-out ────────────────────────────────────
+149
View File
@@ -0,0 +1,149 @@
/**
* Unit tests for Sencho upstream version lookup and registry publish gating.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockGetRemoteDigestResult = vi.fn();
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
vi.mock('../services/registry-api', () => ({
getRemoteDigestResult: (...args: unknown[]) => mockGetRemoteDigestResult(...args),
}));
import { CacheService } from '../services/CacheService';
import {
fetchLatestSenchoVersion,
fetchLatestSenchoVersionInfo,
getLatestVersion,
isSenchoVersionPublished,
} from '../utils/version-check';
function ghRelease(version: string) {
return {
ok: true,
json: async () => ({ tag_name: `v${version}` }),
};
}
function dockerHubTags(...versions: string[]) {
return {
ok: true,
json: async () => ({
results: versions.map(name => ({ name })),
}),
};
}
describe('isSenchoVersionPublished', () => {
beforeEach(() => {
mockGetRemoteDigestResult.mockReset();
});
it('returns true when either mirror has a pullable manifest', async () => {
mockGetRemoteDigestResult
.mockResolvedValueOnce({ ok: false, reason: 'not found' })
.mockResolvedValueOnce({ ok: true, digest: 'sha256:abc' });
await expect(isSenchoVersionPublished('0.94.0')).resolves.toBe(true);
expect(mockGetRemoteDigestResult).toHaveBeenCalledTimes(2);
});
it('returns false when every mirror probe fails', async () => {
mockGetRemoteDigestResult.mockResolvedValue({ ok: false, reason: 'not found' });
await expect(isSenchoVersionPublished('0.94.0')).resolves.toBe(false);
});
it('returns false for invalid semver input', async () => {
await expect(isSenchoVersionPublished('not-a-version')).resolves.toBe(false);
expect(mockGetRemoteDigestResult).not.toHaveBeenCalled();
});
});
describe('fetchLatestSenchoVersionInfo', () => {
beforeEach(() => {
mockFetch.mockReset();
mockGetRemoteDigestResult.mockReset();
mockGetRemoteDigestResult.mockResolvedValue({ ok: false, reason: 'not found' });
});
it('returns GitHub version when the registry manifest is published', async () => {
mockFetch
.mockResolvedValueOnce(ghRelease('0.94.0'))
.mockResolvedValueOnce(dockerHubTags('0.94.0', '0.93.0'));
mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:abc' });
await expect(fetchLatestSenchoVersionInfo()).resolves.toEqual({
version: '0.94.0',
publishPending: false,
});
});
it('falls back to Docker Hub and marks publishPending when GitHub is ahead of the registry', async () => {
mockFetch
.mockResolvedValueOnce(ghRelease('0.94.0'))
.mockResolvedValueOnce(dockerHubTags('0.93.0'));
await expect(fetchLatestSenchoVersionInfo()).resolves.toEqual({
version: '0.93.0',
publishPending: true,
});
expect(mockGetRemoteDigestResult).toHaveBeenCalled();
});
it('uses Docker Hub when GitHub is unavailable', async () => {
mockFetch
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce(dockerHubTags('0.93.0'));
await expect(fetchLatestSenchoVersionInfo()).resolves.toEqual({
version: '0.93.0',
publishPending: false,
});
expect(mockGetRemoteDigestResult).not.toHaveBeenCalled();
});
it('throws when both upstream lookups fail', async () => {
mockFetch
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce({ ok: false });
await expect(fetchLatestSenchoVersionInfo()).rejects.toThrow(
'Both GitHub and Docker Hub version lookups failed',
);
});
});
describe('getLatestVersion cache', () => {
beforeEach(() => {
mockFetch.mockReset();
mockGetRemoteDigestResult.mockReset();
CacheService.getInstance().flush();
});
afterEach(() => {
CacheService.getInstance().flush();
});
it('returns the published semver from the cached lookup', async () => {
mockFetch
.mockResolvedValueOnce(ghRelease('0.94.0'))
.mockResolvedValueOnce(dockerHubTags('0.94.0'));
mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:abc' });
await expect(getLatestVersion()).resolves.toBe('0.94.0');
await expect(getLatestVersion()).resolves.toBe('0.94.0');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('fetchLatestSenchoVersion returns the version string only', async () => {
mockFetch
.mockResolvedValueOnce(ghRelease('0.94.0'))
.mockResolvedValueOnce(dockerHubTags('0.94.0'));
mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:abc' });
await expect(fetchLatestSenchoVersion()).resolves.toBe('0.94.0');
});
});
+15 -7
View File
@@ -8,7 +8,7 @@ import { normalizeImageRef } from './DriftDetectionService';
import { NotificationService } from './NotificationService';
import { FleetUpdateTrackerService } from './FleetUpdateTrackerService';
import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
import { getLatestVersion } from '../utils/version-check';
import { getLatestVersionInfo } from '../utils/version-check';
import { isDebugEnabled } from '../utils/debug';
import { withTimeout, TimeoutError } from '../utils/withTimeout';
@@ -537,10 +537,10 @@ export class MonitorService {
/**
* Check GitHub/Docker Hub for a newer Sencho release and dispatch a
* one-shot notification. Uses getLatestVersion() which wraps CacheService
* (30 min TTL + inflight dedup + stale-on-error) so transient network
* blips do not cause gaps, and the check stays consistent with the Fleet
* update banner.
* one-shot notification. Uses getLatestVersionInfo() which wraps CacheService
* (30 min TTL when published, shorter while a GitHub release awaits registry
* publish + inflight dedup) so transient network blips do not cause gaps,
* and the check stays consistent with the Fleet update banner.
*
* The 6-hour cooldown gate prevents bell spam: it is only advanced on a
* SUCCESSFUL lookup. A failed lookup retries on the next eval cycle
@@ -564,15 +564,23 @@ export class MonitorService {
return;
}
const latest = await getLatestVersion();
if (!isValidVersion(latest)) {
const versionInfo = await getLatestVersionInfo();
if (!versionInfo || !isValidVersion(versionInfo.version)) {
// Network failure (GitHub + Docker Hub both down, no stale cache).
// Do NOT advance the cooldown so the next eval retries.
if (isDebugEnabled()) console.debug('[Monitor:diag] Latest Sencho version unresolvable; will retry next cycle');
return;
}
if (versionInfo.publishPending) {
// GitHub announced a release before the image is pullable. Retry on
// the next eval cycle without advancing the 6-hour cooldown.
if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho release pending registry publish; will retry next cycle');
return;
}
this.lastVersionCheckAt = Date.now();
const latest = versionInfo.version;
const db = DatabaseService.getInstance();
const stateKey = MonitorService.SENCHO_UPDATE_NOTIFIED_KEY;
+93 -16
View File
@@ -1,12 +1,24 @@
import semver from 'semver';
import { CacheService } from '../services/CacheService';
import { getRemoteDigestResult } from '../services/registry-api';
import { isDebugEnabled } from './debug';
import { sanitizeForLog } from './safeLog';
/**
* Fetches the latest Sencho release version from GitHub or Docker Hub.
* Extracted from index.ts so both the fleet endpoint and MonitorService
* can share the same lookup logic.
*
* GitHub releases/latest is the canonical semver source, but availability
* is gated on a pullable registry manifest so operators are not prompted
* before docker-publish.yml finishes pushing the image.
*/
const SENCHO_PUBLISH_MIRRORS = [
{ registry: 'registry-1.docker.io', repo: 'saelix/sencho' },
{ registry: 'ghcr.io', repo: 'studio-saelix/sencho' },
] as const;
async function fetchFromGitHub(): Promise<string | null> {
const res = await fetch('https://api.github.com/repos/studio-saelix/sencho/releases/latest', {
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Sencho' },
@@ -33,48 +45,113 @@ async function fetchFromDockerHub(): Promise<string | null> {
return tags[0];
}
export async function fetchLatestSenchoVersion(): Promise<string> {
/** True when at least one public mirror has a pullable manifest for the semver tag. */
export async function isSenchoVersionPublished(version: string): Promise<boolean> {
if (!semver.valid(version)) return false;
const results = await Promise.all(
SENCHO_PUBLISH_MIRRORS.map(async ({ registry, repo }) => {
const result = await getRemoteDigestResult(registry, repo, version, null);
if (isDebugEnabled() && !result.ok) {
console.debug(
`[VersionCheck] Manifest probe failed for ${sanitizeForLog(registry)}/${sanitizeForLog(repo)}:${sanitizeForLog(version)}: ${sanitizeForLog(result.reason)}`,
);
}
return result.ok;
}),
);
return results.some(Boolean);
}
export interface LatestVersionInfo {
version: string;
/** GitHub announced a newer release than the highest published registry tag. */
publishPending: boolean;
}
export async function fetchLatestSenchoVersionInfo(): Promise<LatestVersionInfo> {
let gh: string | null = null;
let hub: string | null = null;
try {
const gh = await fetchFromGitHub();
if (gh) return gh;
gh = await fetchFromGitHub();
} catch (err) {
// GitHub API fails for private repos or rate limits; try Docker Hub
console.warn('[VersionCheck] GitHub fetch failed:', (err as Error).message);
}
try {
const hub = await fetchFromDockerHub();
if (hub) return hub;
hub = await fetchFromDockerHub();
} catch (err) {
console.warn('[VersionCheck] Docker Hub fetch failed:', (err as Error).message);
}
// Throw so CacheService falls back to a stale value if one exists,
// and so we do not poison the cache with null.
if (gh && semver.valid(gh)) {
if (await isSenchoVersionPublished(gh)) {
return { version: gh, publishPending: false };
}
if (hub && semver.valid(hub)) {
return { version: hub, publishPending: true };
}
throw new Error(`GitHub release ${gh} is not yet published on any registry mirror`);
}
if (hub && semver.valid(hub)) {
return { version: hub, publishPending: false };
}
throw new Error('Both GitHub and Docker Hub version lookups failed');
}
export async function fetchLatestSenchoVersion(): Promise<string> {
const info = await fetchLatestSenchoVersionInfo();
return info.version;
}
/**
* Cached wrapper shared by the Fleet endpoint and MonitorService.
* CacheService provides TTL, inflight deduplication, and stale-on-error
* fallback so transient network blips do not cause user-visible gaps.
*/
const LATEST_VERSION_CACHE_KEY = 'latest-version';
const LATEST_VERSION_INFO_CACHE_KEY = 'latest-version-info';
const LATEST_VERSION_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
const PENDING_PUBLISH_CACHE_TTL = 3 * 60 * 1000; // 3 minutes
export async function getLatestVersion(forceRefresh = false): Promise<string | null> {
let inflightLatestVersionInfo: Promise<LatestVersionInfo> | null = null;
export async function getLatestVersionInfo(forceRefresh = false): Promise<LatestVersionInfo | null> {
const cache = CacheService.getInstance();
if (forceRefresh) {
CacheService.getInstance().invalidate(LATEST_VERSION_CACHE_KEY);
cache.invalidate(LATEST_VERSION_INFO_CACHE_KEY);
}
const cached = cache.get<LatestVersionInfo>(LATEST_VERSION_INFO_CACHE_KEY);
if (cached) return cached;
if (!inflightLatestVersionInfo) {
inflightLatestVersionInfo = (async () => {
try {
const info = await fetchLatestSenchoVersionInfo();
const ttl = info.publishPending ? PENDING_PUBLISH_CACHE_TTL : LATEST_VERSION_CACHE_TTL;
cache.set(LATEST_VERSION_INFO_CACHE_KEY, info, ttl);
return info;
} finally {
inflightLatestVersionInfo = null;
}
})();
}
try {
return await CacheService.getInstance().getOrFetch<string>(
LATEST_VERSION_CACHE_KEY,
LATEST_VERSION_CACHE_TTL,
fetchLatestSenchoVersion,
);
return await inflightLatestVersionInfo;
} catch {
return null;
}
}
export async function getLatestVersion(forceRefresh = false): Promise<string | null> {
const info = await getLatestVersionInfo(forceRefresh);
return info?.version ?? null;
}
// --- Release details (includes body/notes for the changelog tab) ---
export interface SenchoRelease {
+1 -1
View File
@@ -3,7 +3,7 @@ title: Remote Updates
description: Pull the latest Sencho image and recreate the container on any node in the fleet, including the gateway, from the Fleet view.
---
Sencho can update any node in the fleet to the latest published release without ever opening an SSH session. The control instance opens the **Node updates** sheet, dispatches the update to one or more nodes, and watches each one come back online with the new version.
Sencho can update any node in the fleet to the latest published release without ever opening an SSH session. The control instance opens the **Node updates** sheet, dispatches the update to one or more nodes, and watches each one come back online with the new version. Update availability is confirmed against a pullable Docker image on the public registry mirrors, not merely the GitHub Release timestamp, so the **Update** button does not appear until the release image has finished publishing.
This page covers the mechanism: prerequisites, what happens on a node during an update, how completion and failure are detected, and how to recover. The full UI tour for the Node updates sheet itself lives in [Fleet View](/features/fleet-view#node-updates).