mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
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:
@@ -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: '*' });
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user