feat(auto-update): show pending image updates fleet-wide on the Auto-Updates page (#770)

Group readiness cards by node so updates pending on every reachable node
are visible without having to switch the active node. Apply now targets
the owning node directly, and Recheck fans out to every reachable node
in parallel; per-node cooldowns are surfaced in the toast.

Adds POST /image-updates/fleet/refresh and invalidates the fleet
aggregation cache after auto-update execute so the next read reflects
the new state immediately. A small banner appears under the hero when
some online nodes did not respond within the request timeout.
This commit is contained in:
Anso
2026-04-25 08:21:50 -04:00
committed by GitHub
parent c7cdcd082d
commit 58df1a50b3
5 changed files with 337 additions and 74 deletions
@@ -94,6 +94,54 @@ describe('GET /api/image-updates/fleet', () => {
});
});
describe('POST /api/image-updates/fleet/refresh', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app).post('/api/image-updates/fleet/refresh');
expect(res.status).toBe(401);
});
it('rejects non-admin users with 403', async () => {
const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
it('returns triggered/rateLimited/failed arrays for admin caller', async () => {
const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.triggered)).toBe(true);
expect(Array.isArray(res.body.rateLimited)).toBe(true);
expect(Array.isArray(res.body.failed)).toBe(true);
// The single local node should land in either triggered (first hit) or
// rateLimited (cooldown from a prior /refresh in this suite).
const localNodeBuckets = res.body.triggered.length + res.body.rateLimited.length;
expect(localNodeBuckets).toBeGreaterThanOrEqual(1);
});
it('invalidates the fleet aggregation cache', async () => {
const { CacheService } = await import('../services/CacheService');
// Prime the cache by hitting the GET endpoint, then refresh, then
// confirm the cache key was wiped.
await request(app).get('/api/image-updates/fleet').set('Cookie', adminCookie);
expect(CacheService.getInstance().get('fleet-updates')).toBeDefined();
await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie);
expect(CacheService.getInstance().get('fleet-updates')).toBeUndefined();
});
it('downgrades to 402-style upgrade response when license is community', async () => {
const { LicenseService } = await import('../services/LicenseService');
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
try {
const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie);
// requirePaid responds with a non-2xx status carrying an upgrade payload.
expect(res.status).not.toBe(200);
expect(res.status).toBeGreaterThanOrEqual(400);
} finally {
tierSpy.mockRestore();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
}
});
});
describe('POST /api/auto-update/execute', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app).post('/api/auto-update/execute').send({ target: '*' });
+70 -1
View File
@@ -10,7 +10,7 @@ import { LicenseService } from '../services/LicenseService';
import { NotificationService } from '../services/NotificationService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { buildPolicyGateOptions } from '../helpers/policyGate';
import { isValidStackName } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
@@ -110,6 +110,74 @@ imageUpdatesRouter.get('/fleet', authMiddleware, async (_req: Request, res: Resp
}
});
imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
if (!requireAdmin(_req, res)) return;
if (!requirePaid(_req, res)) return;
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const nr = NodeRegistry.getInstance();
const triggered: number[] = [];
const rateLimited: number[] = [];
const failed: number[] = [];
// ImageUpdateService is a per-instance singleton, so the local node's manual
// refresh fires at most once per request regardless of how many local rows
// exist in the schema.
const localNode = nodes.find(n => n.type === 'local');
if (localNode) {
try {
if (ImageUpdateService.getInstance().triggerManualRefresh()) {
triggered.push(localNode.id);
} else {
rateLimited.push(localNode.id);
}
} catch (e) {
console.error(`[ImageUpdates] Local fleet refresh failed for node ${localNode.id}:`, e);
failed.push(localNode.id);
}
}
const remoteNodes = nodes.filter(n => n.type === 'remote' && n.status === 'online' && n.api_url);
const remoteResults = await Promise.allSettled(
remoteNodes.map(async (node) => {
const proxyTarget = nr.getProxyTarget(node.id);
const baseUrl = node.api_url!.replace(/\/$/, '');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REMOTE_NODE_FETCH_TIMEOUT_MS);
try {
const resp = await fetch(`${baseUrl}/api/image-updates/refresh`, {
method: 'POST',
headers: proxyTarget?.apiToken
? { Authorization: `Bearer ${proxyTarget.apiToken}` }
: {},
signal: controller.signal,
});
clearTimeout(timeout);
return { nodeId: node.id, status: resp.status };
} catch (e) {
clearTimeout(timeout);
return { nodeId: node.id, status: 0, error: e };
}
}),
);
for (const entry of remoteResults) {
if (entry.status !== 'fulfilled') continue;
const { nodeId, status } = entry.value;
if (status >= 200 && status < 300) {
triggered.push(nodeId);
} else if (status === 429) {
rateLimited.push(nodeId);
} else {
failed.push(nodeId);
}
}
CacheService.getInstance().invalidate(FLEET_UPDATE_CACHE_KEY);
res.json({ triggered, rateLimited, failed });
});
/**
* Execute auto-update for a single stack (or for every stack on the local
* node when target="*"). This runs on whichever Sencho instance receives
@@ -233,6 +301,7 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
}
}
CacheService.getInstance().invalidate(FLEET_UPDATE_CACHE_KEY);
res.json({ result: results.join('\n') });
} catch (error) {
const msg = getErrorMessage(error, 'Auto-update execution failed');