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
+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');