mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-31 06:02:22 +00:00
feat(schedules): auto-update stacks by Stack Label (#1717)
* feat(schedules): auto-update stacks by Stack Label Add a reusable selector_type/selector_value on scheduled tasks so admins can schedule image updates against live Stack Label membership across the fleet or one node, reusing fleet label resolution and the existing auto-update orchestrator. * fix(image-updates): sanitize auto-update execute failure logs Use a static format string and sanitizeForLog so CodeQL no longer flags user-controlled stack names and error text in the execute catch. * fix(ui): space Scope label from fleet/node segmented control Match the Schedule row layout so the inline SegmentedControl no longer sits flush against the Scope label. * fix(ui): remove redundant wrapper around Scope segmented control
This commit is contained in:
@@ -113,7 +113,7 @@ describe('GET /api/dashboard/configuration', () => {
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
};
|
||||
const idA = db.createScheduledTask({
|
||||
...baseTask,
|
||||
|
||||
@@ -41,6 +41,8 @@ const SCHEMA = `
|
||||
prune_targets TEXT,
|
||||
target_services TEXT,
|
||||
prune_label_filter TEXT,
|
||||
selector_type TEXT,
|
||||
selector_value TEXT,
|
||||
FOREIGN KEY(node_id) REFERENCES nodes(id)
|
||||
);
|
||||
|
||||
|
||||
@@ -412,6 +412,35 @@ describe('POST /api/auto-update/execute', () => {
|
||||
expect(res.body.error).toMatch(/Missing "target"/);
|
||||
});
|
||||
|
||||
it('rejects an empty targets array with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ targets: [] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/non-empty array/);
|
||||
});
|
||||
|
||||
it('rejects invalid names in targets with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ targets: ['ok-stack', '../bad'] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid stack name/);
|
||||
});
|
||||
|
||||
it('accepts targets[] and returns a per-stack summary string', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ targets: ['missing-a', 'missing-b'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.result).toBe('string');
|
||||
expect(res.body.result).toMatch(/missing-a/);
|
||||
expect(res.body.result).toMatch(/missing-b/);
|
||||
});
|
||||
|
||||
it('rejects invalid stack name with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
|
||||
@@ -66,6 +66,8 @@ const SCHEMA = `
|
||||
prune_targets TEXT,
|
||||
target_services TEXT,
|
||||
prune_label_filter TEXT,
|
||||
selector_type TEXT,
|
||||
selector_value TEXT,
|
||||
FOREIGN KEY(node_id) REFERENCES nodes(id)
|
||||
);
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/scheduled-tasks?window_hours=48').set('Cookie', adminCookie);
|
||||
@@ -108,7 +108,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
db.createScheduledTask({
|
||||
name: 'daily-snapshot',
|
||||
@@ -127,7 +127,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
db.createScheduledTask({
|
||||
name: 'system-prune',
|
||||
@@ -146,7 +146,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
last_error: null,
|
||||
prune_targets: JSON.stringify(['images']),
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie);
|
||||
@@ -440,7 +440,7 @@ describe('GET /api/scheduled-tasks/:id', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -468,7 +468,7 @@ describe('PATCH /api/scheduled-tasks/:id/toggle', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: now + 1000, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const off = await request(app).patch(`/api/scheduled-tasks/${id}/toggle`).set('Cookie', adminCookie);
|
||||
@@ -490,7 +490,7 @@ describe('PATCH /api/scheduled-tasks/:id/toggle', () => {
|
||||
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: pinned, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1, run_at: pinned,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1, run_at: pinned,
|
||||
});
|
||||
|
||||
const off = await request(app).patch(`/api/scheduled-tasks/${id}/toggle`).set('Cookie', adminCookie);
|
||||
@@ -515,7 +515,7 @@ describe('DELETE /api/scheduled-tasks/:id', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const del = await request(app).delete(`/api/scheduled-tasks/${id}`).set('Cookie', adminCookie);
|
||||
@@ -534,7 +534,7 @@ describe('GET /api/scheduled-tasks/:id/runs', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/scheduled-tasks/${id}/runs`).set('Cookie', adminCookie);
|
||||
@@ -712,7 +712,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 0,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
@@ -736,7 +736,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: now + 1000, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1,
|
||||
});
|
||||
|
||||
const runAt = new Date(new Date().getFullYear() + 1, 6, 1, 23, 0, 0, 0).getTime();
|
||||
@@ -757,7 +757,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: pinned, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1, run_at: pinned,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1, run_at: pinned,
|
||||
});
|
||||
|
||||
// Editing to a recurring daily schedule sends run_at: null (the frontend
|
||||
@@ -780,7 +780,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: pinned, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1, run_at: pinned,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1, run_at: pinned,
|
||||
});
|
||||
|
||||
// Disabling via Save: next_run_at clears, but the pinned instant is retained
|
||||
@@ -800,7 +800,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: now + 1000, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
@@ -821,7 +821,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -862,7 +862,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
||||
name: 'prune', target_type: 'system', target_id: null, node_id: 1, action: 'prune',
|
||||
cron_expression: '0 4 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
@@ -881,7 +881,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
||||
name: 'prune', target_type: 'system', target_id: null, node_id: 1, action: 'prune',
|
||||
cron_expression: '0 4 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
const remoteNodeId = db.addNode({
|
||||
name: 'remote-prune-update-node', type: 'remote', api_url: 'http://remote.local:1852',
|
||||
@@ -937,7 +937,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: JSON.stringify(['api']),
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
delete_after_run: 0,
|
||||
});
|
||||
|
||||
@@ -966,7 +966,7 @@ describe('PUT /api/scheduled-tasks/:id - cron validation', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1087,3 +1087,83 @@ describe('scheduled-tasks state-invalidate broadcast', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST/PUT /api/scheduled-tasks - stack-label selector', () => {
|
||||
const labelPayload = {
|
||||
name: 'label-update',
|
||||
target_type: 'fleet',
|
||||
target_id: null,
|
||||
node_id: null,
|
||||
action: 'update',
|
||||
cron_expression: '0 3 * * *',
|
||||
enabled: true,
|
||||
selector_type: 'stack-label',
|
||||
selector_value: 'production',
|
||||
};
|
||||
|
||||
it('creates a fleet-wide label update with node_id null', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send(labelPayload);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.selector_type).toBe('stack-label');
|
||||
expect(res.body.selector_value).toBe('production');
|
||||
expect(res.body.node_id).toBeNull();
|
||||
expect(res.body.action).toBe('update');
|
||||
expect(res.body.target_type).toBe('fleet');
|
||||
});
|
||||
|
||||
it('persists selector_value changes through PUT (column map)', async () => {
|
||||
const create = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send(labelPayload);
|
||||
expect(create.status).toBe(201);
|
||||
const id = create.body.id as number;
|
||||
|
||||
const put = await request(app)
|
||||
.put(`/api/scheduled-tasks/${id}`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ selector_type: 'stack-label', selector_value: 'staging' });
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.body.selector_value).toBe('staging');
|
||||
|
||||
const get = await request(app).get(`/api/scheduled-tasks/${id}`).set('Cookie', adminCookie);
|
||||
expect(get.status).toBe(200);
|
||||
expect(get.body.selector_value).toBe('staging');
|
||||
});
|
||||
|
||||
it('still requires node_id for non-selector fleet updates', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'fleet-no-node',
|
||||
target_type: 'fleet',
|
||||
action: 'update',
|
||||
cron_expression: '0 3 * * *',
|
||||
node_id: null,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Fleet update requires node_id.');
|
||||
});
|
||||
|
||||
it('rejects selector fields on unsupported actions', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'restart-with-selector',
|
||||
target_type: 'stack',
|
||||
target_id: 'my-stack',
|
||||
node_id: 1,
|
||||
action: 'restart',
|
||||
cron_expression: '0 3 * * *',
|
||||
selector_type: 'stack-label',
|
||||
selector_value: 'prod',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/selector fields can only be used with update action on fleet target/);
|
||||
});
|
||||
|
||||
it('creates a node-scoped label update when node_id is set', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
...labelPayload,
|
||||
name: 'label-update-node',
|
||||
node_id: 1,
|
||||
selector_value: 'Databases',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.node_id).toBe(1);
|
||||
expect(res.body.selector_value).toBe('Databases');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1875,7 +1875,7 @@ function makeLifecycleTask(action: ScheduledTask['action'], overrides: Partial<S
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
delete_after_run: 0,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -359,30 +359,54 @@ export const autoUpdateRouter = Router();
|
||||
autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
// Honor the node-scoped image-update detection opt-out before any work.
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
res.json({ result: 'Image update detection is disabled for this node; skipped.' });
|
||||
return;
|
||||
}
|
||||
const { target } = req.body as { target?: string };
|
||||
console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target || '')}"`);
|
||||
if (!target || typeof target !== 'string') {
|
||||
res.status(400).json({ error: 'Missing "target" (stack name or "*" for all)' });
|
||||
return;
|
||||
}
|
||||
const { target, targets } = req.body as { target?: string; targets?: unknown };
|
||||
|
||||
let stackNames: string[];
|
||||
if (target === '*') {
|
||||
stackNames = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
if (stackNames.length === 0) {
|
||||
res.json({ result: 'No stacks found on node; skipped.' });
|
||||
if (Array.isArray(targets)) {
|
||||
if (targets.length === 0) {
|
||||
res.status(400).json({ error: '"targets" must be a non-empty array of stack names' });
|
||||
return;
|
||||
}
|
||||
if (targets.length > 500) {
|
||||
res.status(400).json({ error: '"targets" accepts at most 500 stack names' });
|
||||
return;
|
||||
}
|
||||
if (!targets.every((t): t is string => typeof t === 'string' && isValidStackName(t))) {
|
||||
res.status(400).json({ error: 'Invalid stack name in targets' });
|
||||
return;
|
||||
}
|
||||
// Deduplicate while preserving order.
|
||||
const seen = new Set<string>();
|
||||
stackNames = [];
|
||||
for (const name of targets) {
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
stackNames.push(name);
|
||||
}
|
||||
console.log(`[AutoUpdate] Execute requested: targets=${stackNames.length}`);
|
||||
} else if (typeof target === 'string' && target.length > 0) {
|
||||
console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target)}"`);
|
||||
if (target === '*') {
|
||||
stackNames = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
if (stackNames.length === 0) {
|
||||
res.json({ result: 'No stacks found on node; skipped.' });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!isValidStackName(target)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
stackNames = [target];
|
||||
}
|
||||
} else {
|
||||
if (!isValidStackName(target)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
stackNames = [target];
|
||||
res.status(400).json({ error: 'Missing "target" (stack name or "*") or "targets" (stack name array)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const docker = DockerController.getInstance(req.nodeId);
|
||||
@@ -510,7 +534,11 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e, String(e));
|
||||
results.push(`Stack "${stackName}" failed: ${msg}`);
|
||||
console.error(`[AutoUpdate] Failed for stack "${stackName}":`, e);
|
||||
console.error(
|
||||
'[AutoUpdate] Failed for stack %s: %s',
|
||||
sanitizeForLog(stackName),
|
||||
sanitizeForLog(msg),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,17 @@ function broadcastScheduledTasksChanged(): void {
|
||||
|
||||
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
|
||||
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
|
||||
const STACK_LABEL_SELECTOR = 'stack-label';
|
||||
const LABEL_NAME_RE = /^[a-zA-Z0-9 -]+$/;
|
||||
|
||||
function isStackLabelSelector(selectorType: unknown): boolean {
|
||||
return selectorType === STACK_LABEL_SELECTOR;
|
||||
}
|
||||
|
||||
/** True when this update+fleet task uses a stack-label selector (node_id may be null). */
|
||||
function usesStackLabelSelector(action: BackendScheduledAction, targetType: TargetType, selectorType: unknown): boolean {
|
||||
return action === 'update' && targetType === 'fleet' && isStackLabelSelector(selectorType);
|
||||
}
|
||||
|
||||
function parsePositiveNodeId(nodeId: unknown): number | null {
|
||||
if (typeof nodeId !== 'number' && typeof nodeId !== 'string') return null;
|
||||
@@ -98,17 +109,26 @@ function validateContainerTarget(targetType: TargetType, targetId: unknown, node
|
||||
|
||||
/**
|
||||
* Shared guard for non-stack actions that require a node. Stack actions use
|
||||
* validateStackTarget because they also require target_id.
|
||||
* validateStackTarget because they also require target_id. Label-targeted
|
||||
* fleet updates may omit node_id (entire fleet); pass selectorType so that
|
||||
* path is allowed.
|
||||
*/
|
||||
function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null {
|
||||
function validateActionNode(
|
||||
action: BackendScheduledAction,
|
||||
targetType: TargetType,
|
||||
nodeId: unknown,
|
||||
selectorType?: unknown,
|
||||
): string | null {
|
||||
if (targetType === 'stack' || targetType === 'container') return null;
|
||||
const def = getScheduledActionDefinition(action);
|
||||
if (!def?.requiresNode) return null;
|
||||
|
||||
const labelSingular = nodeRequirementLabel(action, targetType);
|
||||
const labelPlural = localNodeRequirementLabel(action);
|
||||
const labelFleetUpdate = usesStackLabelSelector(action, targetType, selectorType);
|
||||
|
||||
if (nodeId == null) {
|
||||
if (labelFleetUpdate) return null;
|
||||
return action === 'update' && targetType === 'fleet'
|
||||
? ERR_FLEET_NODE_REQUIRED
|
||||
: `${labelSingular} action requires node_id.`;
|
||||
@@ -124,13 +144,15 @@ function validateActionNode(action: BackendScheduledAction, targetType: TargetTy
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Shared validation for prune_targets, target_services, prune_label_filter. Returns an error string or null. */
|
||||
/** Shared validation for prune_targets, target_services, prune_label_filter, selector_*. Returns an error string or null. */
|
||||
function validateOptionalFields(
|
||||
action: BackendScheduledAction,
|
||||
targetType: TargetType,
|
||||
prune_targets: unknown,
|
||||
target_services: unknown,
|
||||
prune_label_filter: unknown,
|
||||
selector_type?: unknown,
|
||||
selector_value?: unknown,
|
||||
): string | null {
|
||||
if (prune_targets !== undefined && prune_targets !== null) {
|
||||
if (!Array.isArray(prune_targets) || prune_targets.length === 0
|
||||
@@ -155,9 +177,39 @@ function validateOptionalFields(
|
||||
return 'prune_label_filter can only be used with prune action';
|
||||
}
|
||||
}
|
||||
|
||||
const selectorPresent = (selector_type !== undefined && selector_type !== null)
|
||||
|| (selector_value !== undefined && selector_value !== null);
|
||||
if (selectorPresent) {
|
||||
if (action !== 'update' || targetType !== 'fleet') {
|
||||
return 'selector fields can only be used with update action on fleet target';
|
||||
}
|
||||
if (selector_type !== STACK_LABEL_SELECTOR) {
|
||||
return 'selector_type must be "stack-label"';
|
||||
}
|
||||
if (typeof selector_value !== 'string' || selector_value.trim().length === 0 || selector_value.trim().length > 30) {
|
||||
return 'selector_value is required and must be 1-30 characters';
|
||||
}
|
||||
if (!LABEL_NAME_RE.test(selector_value.trim())) {
|
||||
return 'selector_value may only contain letters, numbers, spaces, and hyphens';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSelectorFields(
|
||||
action: BackendScheduledAction,
|
||||
targetType: TargetType,
|
||||
selector_type: unknown,
|
||||
selector_value: unknown,
|
||||
): { selector_type: string | null; selector_value: string | null } {
|
||||
if (action === 'update' && targetType === 'fleet' && isStackLabelSelector(selector_type)
|
||||
&& typeof selector_value === 'string' && selector_value.trim()) {
|
||||
return { selector_type: STACK_LABEL_SELECTOR, selector_value: selector_value.trim() };
|
||||
}
|
||||
return { selector_type: null, selector_value: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression for Scheduled Operations. The scheduler ticks once
|
||||
* per minute, so an expression with a leading seconds field (6 or more fields)
|
||||
@@ -238,7 +290,11 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
|
||||
scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run, run_at } = req.body;
|
||||
const {
|
||||
name, target_type, target_id, node_id, action, cron_expression, enabled,
|
||||
prune_targets, target_services, prune_label_filter, selector_type, selector_value,
|
||||
delete_after_run, run_at,
|
||||
} = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'Name is required' }); return;
|
||||
@@ -253,14 +309,16 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
const targetErr = validateActionTarget(action, target_type);
|
||||
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
||||
|
||||
const nodeErr = validateActionNode(action, target_type, node_id);
|
||||
const nodeErr = validateActionNode(action, target_type, node_id, selector_type);
|
||||
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
||||
const stackTargetErr = validateStackTarget(target_type, target_id, node_id);
|
||||
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
|
||||
const containerTargetErr = validateContainerTarget(target_type, target_id, node_id);
|
||||
if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; }
|
||||
|
||||
const optionalErr = validateOptionalFields(action, target_type, prune_targets, target_services, prune_label_filter);
|
||||
const optionalErr = validateOptionalFields(
|
||||
action, target_type, prune_targets, target_services, prune_label_filter, selector_type, selector_value,
|
||||
);
|
||||
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
|
||||
|
||||
const cronErr = validateCronExpression(cron_expression);
|
||||
@@ -282,7 +340,14 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
: (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression));
|
||||
const normalizedTargetId =
|
||||
target_type === 'stack' || target_type === 'container' ? target_id : null;
|
||||
const normalizedNodeId = actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null;
|
||||
const labelSelector = usesStackLabelSelector(action, target_type, selector_type);
|
||||
const normalizedNodeId = labelSelector
|
||||
? (node_id == null || node_id === '' ? null : parsePositiveNodeId(node_id))
|
||||
: (actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null);
|
||||
if (labelSelector && node_id != null && node_id !== '' && normalizedNodeId === null) {
|
||||
res.status(400).json({ error: 'Fleet update action requires a valid node_id.' }); return;
|
||||
}
|
||||
const selectors = normalizeSelectorFields(action, target_type, selector_type, selector_value);
|
||||
|
||||
const id = DatabaseService.getInstance().createScheduledTask({
|
||||
name: name.trim(),
|
||||
@@ -302,6 +367,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
prune_targets: action === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null,
|
||||
target_services: action === 'restart' && target_type === 'stack' && target_services ? JSON.stringify(target_services) : null,
|
||||
prune_label_filter: action === 'prune' && prune_label_filter ? prune_label_filter.trim() : null,
|
||||
selector_type: selectors.selector_type,
|
||||
selector_value: selectors.selector_value,
|
||||
delete_after_run: delete_after_run ? 1 : 0,
|
||||
run_at: pinnedRunAt,
|
||||
});
|
||||
@@ -340,7 +407,11 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
const existing = db.getScheduledTask(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
||||
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run, run_at } = req.body;
|
||||
const {
|
||||
name, target_type, target_id, node_id, action, cron_expression, enabled,
|
||||
prune_targets, target_services, prune_label_filter, selector_type, selector_value,
|
||||
delete_after_run, run_at,
|
||||
} = req.body;
|
||||
|
||||
if (target_type !== undefined && !(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) {
|
||||
res.status(400).json({ error: 'Invalid target_type' }); return;
|
||||
@@ -354,13 +425,18 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
const finalTargetId = finalTargetType === 'stack' || finalTargetType === 'container'
|
||||
? (target_id !== undefined ? target_id : existing.target_id)
|
||||
: null;
|
||||
const finalNodeId = actionRequiresNode(finalAction)
|
||||
const finalSelectorType = selector_type !== undefined ? selector_type : existing.selector_type;
|
||||
const finalSelectorValue = selector_value !== undefined ? selector_value : existing.selector_value;
|
||||
const labelSelector = usesStackLabelSelector(finalAction, finalTargetType, finalSelectorType);
|
||||
const finalNodeId = labelSelector
|
||||
? (node_id !== undefined ? node_id : existing.node_id)
|
||||
: null;
|
||||
: (actionRequiresNode(finalAction)
|
||||
? (node_id !== undefined ? node_id : existing.node_id)
|
||||
: null);
|
||||
const targetErr = validateActionTarget(finalAction, finalTargetType);
|
||||
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
||||
|
||||
const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId);
|
||||
const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId, finalSelectorType);
|
||||
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
||||
|
||||
const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId);
|
||||
@@ -369,7 +445,11 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
const containerTargetErr = validateContainerTarget(finalTargetType, finalTargetId, finalNodeId);
|
||||
if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; }
|
||||
|
||||
const optionalErr = validateOptionalFields(finalAction, finalTargetType, prune_targets, target_services, prune_label_filter);
|
||||
const optionalErr = validateOptionalFields(
|
||||
finalAction, finalTargetType, prune_targets, target_services, prune_label_filter,
|
||||
selector_type !== undefined ? selector_type : finalSelectorType,
|
||||
selector_value !== undefined ? selector_value : finalSelectorValue,
|
||||
);
|
||||
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
|
||||
|
||||
if (cron_expression !== undefined) {
|
||||
@@ -391,7 +471,17 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
if (target_id !== undefined || (finalTargetType !== 'stack' && finalTargetType !== 'container')) {
|
||||
updates.target_id = finalTargetId || null;
|
||||
}
|
||||
if (node_id !== undefined || !actionRequiresNode(finalAction)) {
|
||||
// Label-targeted fleet updates keep node_id when provided (or existing);
|
||||
// clear only when the caller explicitly sends null/empty for fleet-wide, or
|
||||
// when the action no longer requires a node and is not a label selector.
|
||||
if (labelSelector) {
|
||||
if (node_id !== undefined) {
|
||||
updates.node_id = node_id == null || node_id === '' ? null : parsePositiveNodeId(node_id);
|
||||
if (node_id != null && node_id !== '' && updates.node_id === null) {
|
||||
res.status(400).json({ error: 'Fleet update action requires a valid node_id.' }); return;
|
||||
}
|
||||
}
|
||||
} else if (node_id !== undefined || !actionRequiresNode(finalAction)) {
|
||||
updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null;
|
||||
}
|
||||
if (action !== undefined) updates.action = finalAction;
|
||||
@@ -414,6 +504,12 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
} else if (finalAction !== 'prune') {
|
||||
updates.prune_label_filter = null;
|
||||
}
|
||||
if (selector_type !== undefined || selector_value !== undefined
|
||||
|| finalAction !== 'update' || finalTargetType !== 'fleet') {
|
||||
const selectors = normalizeSelectorFields(finalAction, finalTargetType, finalSelectorType, finalSelectorValue);
|
||||
updates.selector_type = selectors.selector_type;
|
||||
updates.selector_value = selectors.selector_value;
|
||||
}
|
||||
if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0;
|
||||
|
||||
// Persist a re-supplied run_at to its column (a number pins a one-shot; null
|
||||
|
||||
@@ -714,6 +714,10 @@ export interface ScheduledTask {
|
||||
prune_targets: string | null;
|
||||
target_services: string | null;
|
||||
prune_label_filter: string | null;
|
||||
/** Optional dynamic target selector; currently only 'stack-label'. */
|
||||
selector_type: string | null;
|
||||
/** Selector payload (e.g. Stack Label name when selector_type is stack-label). */
|
||||
selector_value: string | null;
|
||||
delete_after_run?: number;
|
||||
// Absolute epoch-ms fire time for a one-time ('once') schedule. A 5-field
|
||||
// cron has no year field, so the chosen instant (including year) is persisted
|
||||
@@ -1936,6 +1940,8 @@ export class DatabaseService {
|
||||
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'target_services', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'prune_label_filter', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'selector_type', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'selector_value', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'delete_after_run', 'INTEGER DEFAULT 0');
|
||||
maybeAddCol('scheduled_tasks', 'run_at', 'INTEGER DEFAULT NULL');
|
||||
|
||||
@@ -6041,13 +6047,14 @@ export class DatabaseService {
|
||||
|
||||
public createScheduledTask(task: Omit<ScheduledTask, 'id'>): number {
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, selector_type, selector_value, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
task.name, task.target_type, task.target_id, task.node_id,
|
||||
task.action, task.cron_expression, task.enabled, task.created_by,
|
||||
task.created_at, task.updated_at, task.last_run_at, task.next_run_at,
|
||||
task.last_status, task.last_error, task.prune_targets, task.target_services,
|
||||
task.prune_label_filter, task.delete_after_run ?? 0, task.run_at ?? null
|
||||
task.prune_label_filter, task.selector_type ?? null, task.selector_value ?? null,
|
||||
task.delete_after_run ?? 0, task.run_at ?? null
|
||||
);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
@@ -6064,6 +6071,8 @@ export class DatabaseService {
|
||||
last_status: updates.last_status, last_error: updates.last_error,
|
||||
prune_targets: updates.prune_targets, target_services: updates.target_services,
|
||||
prune_label_filter: updates.prune_label_filter,
|
||||
selector_type: updates.selector_type,
|
||||
selector_value: updates.selector_value,
|
||||
delete_after_run: updates.delete_after_run,
|
||||
run_at: updates.run_at,
|
||||
};
|
||||
|
||||
@@ -739,6 +739,10 @@ export class SchedulerService {
|
||||
}
|
||||
|
||||
private async executeUpdate(task: ScheduledTask): Promise<string> {
|
||||
if (task.selector_type === 'stack-label') {
|
||||
return this.executeUpdateByStackLabel(task);
|
||||
}
|
||||
|
||||
if (task.node_id == null) {
|
||||
throw new Error('Auto-update requires node_id');
|
||||
}
|
||||
@@ -791,6 +795,140 @@ export class SchedulerService {
|
||||
return results.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve live stack-label membership (fleet-wide or one node) and run the
|
||||
* existing per-stack auto-update path. Remotes receive an explicit stack
|
||||
* list; they do not evaluate the selector themselves.
|
||||
*/
|
||||
private async executeUpdateByStackLabel(task: ScheduledTask): Promise<string> {
|
||||
const labelName = (task.selector_value ?? '').trim();
|
||||
if (!labelName) {
|
||||
throw new Error('Label-targeted auto-update requires selector_value');
|
||||
}
|
||||
|
||||
const { collectFleetLabelSummaries } = await import('../helpers/fleetLabelSummary');
|
||||
let summaries = await collectFleetLabelSummaries();
|
||||
if (task.node_id != null) {
|
||||
summaries = summaries.filter(s => s.nodeId === task.node_id);
|
||||
if (summaries.length === 0) {
|
||||
throw new Error(`Target node (id=${task.node_id}) no longer exists`);
|
||||
}
|
||||
}
|
||||
|
||||
type NodePlan = {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
reachable: boolean;
|
||||
stacks: string[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const plans: NodePlan[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const summary of summaries) {
|
||||
if (!summary.reachable) {
|
||||
plans.push({
|
||||
nodeId: summary.nodeId,
|
||||
nodeName: summary.nodeName,
|
||||
reachable: false,
|
||||
stacks: [],
|
||||
error: summary.error ?? 'unreachable',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const match = summary.labels.find(l => l.name === labelName);
|
||||
const stacks: string[] = [];
|
||||
if (match) {
|
||||
for (const stackName of match.stackNames) {
|
||||
const key = `${summary.nodeId}\0${stackName}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
stacks.push(stackName);
|
||||
}
|
||||
}
|
||||
plans.push({
|
||||
nodeId: summary.nodeId,
|
||||
nodeName: summary.nodeName,
|
||||
reachable: true,
|
||||
stacks,
|
||||
});
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`Selector: stack-label="${labelName}" · scope=${task.node_id == null ? 'entire fleet' : `node ${task.node_id}`}`,
|
||||
];
|
||||
for (const plan of plans) {
|
||||
if (!plan.reachable) {
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): unreachable (${plan.error})`);
|
||||
} else if (plan.stacks.length === 0) {
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): no stacks with label "${labelName}"`);
|
||||
} else {
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): ${plan.stacks.length} stack(s) → ${plan.stacks.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const matchedStacks = plans.reduce((n, p) => n + p.stacks.length, 0);
|
||||
const unreachableCount = plans.filter(p => !p.reachable).length;
|
||||
if (matchedStacks === 0 && unreachableCount === 0) {
|
||||
lines.push(`No stacks currently match label "${labelName}"; skipped.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
let materialFailure = unreachableCount > 0;
|
||||
const work = plans.filter(p => p.reachable && p.stacks.length > 0);
|
||||
const NODE_CONCURRENCY = 3;
|
||||
// Label-targeted runs fail closed on material stack failures or
|
||||
// unreachable scoped nodes (unlike plain node fleet update, which
|
||||
// historically returns failure lines as a successful run output).
|
||||
|
||||
const looksLikeStackFailure = (text: string): boolean =>
|
||||
/^Stack ".+" failed:/m.test(text);
|
||||
|
||||
const runNode = async (plan: NodePlan): Promise<void> => {
|
||||
const node = NodeRegistry.getInstance().getNode(plan.nodeId);
|
||||
try {
|
||||
if (node?.type === 'remote') {
|
||||
const remoteOut = await this.executeUpdateRemoteTargets(plan.nodeId, plan.stacks);
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}) results:\n${remoteOut}`);
|
||||
if (looksLikeStackFailure(remoteOut)) materialFailure = true;
|
||||
} else {
|
||||
const docker = DockerController.getInstance(plan.nodeId);
|
||||
const imageUpdateService = ImageUpdateService.getInstance();
|
||||
const stackLines: string[] = [];
|
||||
for (const stackName of plan.stacks) {
|
||||
try {
|
||||
const out = await this.executeUpdateForStack(
|
||||
stackName, plan.nodeId, docker, imageUpdateService, true,
|
||||
);
|
||||
stackLines.push(out);
|
||||
} catch (e) {
|
||||
materialFailure = true;
|
||||
const msg = getErrorMessage(e, String(e));
|
||||
stackLines.push(`Stack "${stackName}" failed: ${msg}`);
|
||||
console.error(`[SchedulerService] Label auto-update failed for stack "${stackName}" on node ${plan.nodeId}:`, e);
|
||||
}
|
||||
}
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}) results:\n${stackLines.join('\n')}`);
|
||||
}
|
||||
} catch (e) {
|
||||
materialFailure = true;
|
||||
const msg = getErrorMessage(e, String(e));
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): failed (${msg})`);
|
||||
console.error(`[SchedulerService] Label auto-update failed for node ${plan.nodeId}:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < work.length; i += NODE_CONCURRENCY) {
|
||||
const batch = work.slice(i, i + NODE_CONCURRENCY);
|
||||
await Promise.all(batch.map(runNode));
|
||||
}
|
||||
|
||||
if (materialFailure) {
|
||||
throw new Error(lines.join('\n'));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy auto-update execution to a remote Sencho instance.
|
||||
* The remote node runs the image checks and compose update locally.
|
||||
@@ -829,6 +967,55 @@ export class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Proxy auto-update for an explicit stack list on a remote node (label selector). */
|
||||
private async executeUpdateRemoteTargets(nodeId: number, targets: string[]): Promise<string> {
|
||||
const proxyTarget = this.requireRemoteProxyTarget(nodeId);
|
||||
const baseUrl = proxyTarget.apiUrl.replace(/\/$/, '');
|
||||
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[SchedulerService] executeUpdateRemoteTargets: node=${nodeId} count=${targets.length}`);
|
||||
}
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/auto-update/execute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${proxyTarget.apiToken}`,
|
||||
[PROXY_TIER_HEADER]: proxyHeaders.tier,
|
||||
},
|
||||
body: JSON.stringify({ targets }),
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
});
|
||||
|
||||
// Older remotes only accept { target }. Fall back to one call per
|
||||
// stack so mixed-version fleets still complete the label schedule.
|
||||
if (response.status === 400) {
|
||||
const detail = await this.remoteResponseDetail(response);
|
||||
if (/target/i.test(detail)) {
|
||||
const parts: string[] = [];
|
||||
for (const stackName of targets) {
|
||||
parts.push(await this.executeUpdateRemote(nodeId, stackName));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
throw new Error(this.remoteProxyFailureMessage(nodeId, detail));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(this.remoteProxyFailureMessage(nodeId, await this.remoteResponseDetail(response)));
|
||||
}
|
||||
|
||||
const body = await response.json() as { result?: string };
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[SchedulerService] executeUpdateRemoteTargets: completed in ${Date.now() - startTime}ms`);
|
||||
}
|
||||
return body.result || 'Remote auto-update completed (no details returned).';
|
||||
} catch (err) {
|
||||
this.rethrowRemoteProxyError(nodeId, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy a stack lifecycle action to a remote Sencho instance. ComposeService,
|
||||
* DockerController, and FileSystemService are local-only, so for a remote node
|
||||
|
||||
@@ -99,6 +99,7 @@ Auto-update is opt-in per stack. A stack participates in unattended updates only
|
||||
|
||||
- **Per-stack schedule.** Create a **Auto-update Stack** task targeting that stack alone. Only this stack is updated when the cron fires.
|
||||
- **Fleet-wide schedule.** Create a **Auto-update All Stacks on Node** task targeting a node. Every stack on that node is checked when the cron fires, and stacks with same-tag digest drift are pulled and recreated. If you do not want every stack covered, create per-stack schedules instead.
|
||||
- **Label schedule.** Create an **Auto-update stacks by label** task, pick a Stack Label, and choose Entire fleet or one node. At each run Sencho resolves the stacks that currently carry that label and updates those with newer images. Assigning or removing the label changes the next run without editing the schedule.
|
||||
- **Stack list dot.** Image-update *detection* runs on the configured interval (every 2 hours by default) regardless of whether any schedule is configured. The sidebar dot and the readiness board still show available updates so you can decide what to do with them.
|
||||
- **Manual updates are always available.** The lifecycle **Update** action pulls and recreates the tags currently written in Compose, independent of any scheduled task. It does not rewrite a higher-tag advisory into the Compose file.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Open the **Schedules** tab from the top navigation bar. The page opens on the Ti
|
||||
The Timeline plots every firing of every enabled task across a rolling 24-hour window starting from the current minute.
|
||||
|
||||
- **Masthead.** A `NEXT 24 HOURS` kicker, an italic display heading, the window's start and end timestamps in a monospace range, and a right-anchored **Next** pill that reads out the time and task name of the next firing and a relative countdown.
|
||||
- **Five lanes.** Lifecycle (label blue), Updates (success green), Security (label purple), Upkeep (warning amber), and Backups (brand cyan). The Lifecycle lane holds stack lifecycle actions (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down) and standalone container actions (Restart Container, Stop Container, Start Container); Updates holds per-node and fleet image updates; Security holds vulnerability scans; Upkeep holds node resource prunes; Backups holds fleet snapshots.
|
||||
- **Five lanes.** Lifecycle (label blue), Updates (success green), Security (label purple), Upkeep (warning amber), and Backups (brand cyan). The Lifecycle lane holds stack lifecycle actions (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down) and standalone container actions (Restart Container, Stop Container, Start Container); Updates holds per-stack, per-node, and label-targeted image updates; Security holds vulnerability scans; Upkeep holds node resource prunes; Backups holds fleet snapshots.
|
||||
- **Pills.** One pill per firing within the window, positioned proportionally to the firing's time. Each pill shows the firing time and a target: the stack for stack actions, the container name for container actions, the selected node for prune and scan, and "Entire fleet" for a fleet snapshot. Hover a pill for the full detail (action, task name, and node). Pills are color-matched to their lane. Click a pill to open the run history sheet for that task.
|
||||
- **Now rail.** A glowing vertical rail at the current minute, anchored to the left of the track at page open and drifting right as time passes (the page recomputes positions periodically).
|
||||
- **Axis.** Six monospace time ticks run along the bottom, evenly spaced through the window.
|
||||
@@ -61,6 +61,7 @@ The All tasks toggle swaps the lane track for a sortable table.
|
||||
| **Restart Stack** | A specific stack (optionally specific services) on a specific node | Restarts all or selected containers in the stack. |
|
||||
| **Auto-update Stack** | A specific stack on a specific node | Checks each image in the stack for a newer tag and recreates the stack if any image has an update. See [Auto-Update Policies](/features/auto-update-policies) for the companion review board. |
|
||||
| **Auto-update All Stacks on Node** | A specific node | Runs the auto-update check across every stack on the node. Pair with **Auto-update Stack** rows when you want different cadences for specific stacks. |
|
||||
| **Auto-update stacks by label** | A Stack Label across the entire fleet, or on one selected node | At each run, resolves stacks that currently carry the chosen Stack Label and updates those with newer images. Membership is dynamic: assigning or removing the label changes the next run without editing the schedule. |
|
||||
| **Create Fleet Snapshot** | The whole fleet | Creates a versioned, fleet-wide snapshot of every node's compose files and `.env` files. See [Fleet Backups](/features/fleet-backups). |
|
||||
| **Prune Node Resources** | A local node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. Runs on local nodes only. |
|
||||
| **Scan Node Images** | A local node | Runs Trivy against every image on the node and persists the results. Requires Trivy to be installed on the target node ([Installing Trivy](/operations/trivy-setup)). Runs on local nodes only. |
|
||||
@@ -93,6 +94,7 @@ Conditional fields per action:
|
||||
- **Stack actions** (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Auto-update Stack, Stop Stack, Take Stack Down) add a **Node** combobox and a **Stack** combobox. When the selected stack's compose file defines more than one service, Restart Stack additionally renders a **Services** checkbox grid so you can scope the restart to a subset instead of the entire stack; single-service stacks skip the grid since there is nothing to scope.
|
||||
- **Container actions** (Restart Container, Stop Container, Start Container) add a **Node** combobox and a **Container** combobox listing every container on that node (running and stopped). The picker shows each container's name, state, and image. When the container is not part of a Sencho stack, helper text explains that the schedule targets the container by node and name.
|
||||
- **Auto-update All Stacks on Node** adds a **Node** combobox. The helper text "Checks every stack on the selected node and updates stacks with newer images" appears above, next to the Runtime change badge.
|
||||
- **Auto-update stacks by label** adds a Stack Label name field, a scope control (Entire fleet or Selected node), an optional Node combobox when scoped to one node, and a read-only current-match preview. The preview is informational; membership is resolved again at each run. Saving with zero current matches is allowed.
|
||||
- **Scan Node Images** adds a **Node** combobox listing local nodes only. The helper text "Runs Trivy against images on the selected local node and records the findings" and Read-only badge appear above.
|
||||
- **Prune Node Resources** adds a **Node** combobox listing local nodes only, then a **Prune Targets** group (Containers, Images, Networks, Volumes; all selected by default) and a **Label Filter** input for scoping the prune to resources matching a Docker label.
|
||||
- **Create Fleet Snapshot** shows a read-only **Scope: Entire fleet** summary instead of a Node or Stack picker, because it captures every node.
|
||||
|
||||
@@ -202,6 +202,9 @@ When the chosen label name exists with different colors on different nodes, the
|
||||
<Card title="Fleet Actions" icon="bolt" href="/features/fleet-actions">
|
||||
The full reference for every cross-node action, including Stop by label and Bulk label assign.
|
||||
</Card>
|
||||
<Card title="Scheduled Operations" icon="calendar-clock" href="/features/scheduled-operations">
|
||||
Schedule auto-updates that target stacks by Stack Label across the fleet or on one node.
|
||||
</Card>
|
||||
<Card title="Alerts & Notifications" icon="bell" href="/features/alerts-notifications">
|
||||
The complete Mute Rules field reference and how suppression interacts with routing.
|
||||
</Card>
|
||||
|
||||
@@ -41,8 +41,25 @@ import {
|
||||
RISK_DOT_CLASSES,
|
||||
RISK_LABEL,
|
||||
} from '@/lib/scheduledActions';
|
||||
import { LabelNameAutocomplete, type LabelNameSuggestion } from '@/components/labels/LabelNameAutocomplete';
|
||||
|
||||
const DEFAULT_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'];
|
||||
|
||||
interface LabelMatchPreviewNode {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
reachable: boolean;
|
||||
labelExists: boolean;
|
||||
stackCount: number;
|
||||
stackNames: string[];
|
||||
error?: string;
|
||||
}
|
||||
interface LabelMatchPreview {
|
||||
matchedNodes: number;
|
||||
matchedStacks: number;
|
||||
unreachableNodes: number;
|
||||
perNode: LabelMatchPreviewNode[];
|
||||
}
|
||||
const DEFAULT_SIMPLE_SCHEDULE: SimpleSchedule = {
|
||||
frequency: 'daily', minute: 0, hour: 3, weekdays: [], dayOfMonth: 1, date: null,
|
||||
};
|
||||
@@ -114,6 +131,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const [formPruneTargets, setFormPruneTargets] = useState<string[]>(DEFAULT_PRUNE_TARGETS);
|
||||
const [formTargetServices, setFormTargetServices] = useState<string[]>([]);
|
||||
const [formPruneLabelFilter, setFormPruneLabelFilter] = useState('');
|
||||
const [formSelectorValue, setFormSelectorValue] = useState('');
|
||||
const [formLabelScope, setFormLabelScope] = useState<'fleet' | 'node'>('fleet');
|
||||
const [labelSuggestions, setLabelSuggestions] = useState<LabelNameSuggestion[]>([]);
|
||||
const [labelPreview, setLabelPreview] = useState<
|
||||
{ kind: 'idle' } | { kind: 'loading' } | { kind: 'unavailable' } | { kind: 'ready'; data: LabelMatchPreview }
|
||||
>({ kind: 'idle' });
|
||||
const [availableServices, setAvailableServices] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [runningTaskId, setRunningTaskId] = useState<number | null>(null);
|
||||
@@ -236,6 +259,65 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
return () => { cancelled = true; };
|
||||
}, [formAction, formTargetId, formNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen || formAction !== 'update-by-label') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/labels/suggestions', { localOnly: true });
|
||||
if (!res.ok || cancelled) return;
|
||||
const body = await res.json() as { suggestions?: LabelNameSuggestion[] };
|
||||
const list = Array.isArray(body.suggestions)
|
||||
? body.suggestions.filter(s => s && typeof s.name === 'string' && s.scope === 'stack')
|
||||
: [];
|
||||
if (!cancelled) setLabelSuggestions(list);
|
||||
} catch {
|
||||
if (!cancelled) setLabelSuggestions([]);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [dialogOpen, formAction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen || formAction !== 'update-by-label') {
|
||||
setLabelPreview({ kind: 'idle' });
|
||||
return;
|
||||
}
|
||||
const trimmed = formSelectorValue.trim();
|
||||
if (!trimmed) {
|
||||
setLabelPreview({ kind: 'idle' });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLabelPreview({ kind: 'loading' });
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/labels/match-preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ labelName: trimmed }),
|
||||
localOnly: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
setLabelPreview({ kind: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as LabelMatchPreview;
|
||||
if (!data || !Array.isArray(data.perNode)) {
|
||||
setLabelPreview({ kind: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
setLabelPreview({ kind: 'ready', data });
|
||||
} catch {
|
||||
if (!cancelled) setLabelPreview({ kind: 'unavailable' });
|
||||
}
|
||||
}, 500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [dialogOpen, formAction, formSelectorValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
const actionDef = getActionById(formAction);
|
||||
@@ -267,6 +349,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
setFormPruneTargets(DEFAULT_PRUNE_TARGETS);
|
||||
setFormTargetServices([]);
|
||||
setFormPruneLabelFilter('');
|
||||
setFormSelectorValue('');
|
||||
setFormLabelScope('fleet');
|
||||
setLabelPreview({ kind: 'idle' });
|
||||
setDialogOpen(true);
|
||||
if (nodeId) fetchStacks(nodeId);
|
||||
};
|
||||
@@ -299,6 +384,13 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
task.target_services ? JSON.parse(task.target_services) : []
|
||||
);
|
||||
setFormPruneLabelFilter(task.prune_label_filter || '');
|
||||
setFormSelectorValue(task.selector_value || '');
|
||||
setFormLabelScope(
|
||||
task.selector_type === 'stack-label'
|
||||
? (task.node_id == null ? 'fleet' : 'node')
|
||||
: 'fleet',
|
||||
);
|
||||
setLabelPreview({ kind: 'idle' });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -328,6 +420,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
// every recurring shape and for Advanced mode, where the cron is authoritative.
|
||||
const runAt = scheduleMode === 'simple' ? getOnceRunAt(simpleSchedule) : null;
|
||||
|
||||
const isLabelUpdate = formAction === 'update-by-label';
|
||||
const body: Record<string, unknown> = {
|
||||
name: formName,
|
||||
target_type: actionDef.targetType,
|
||||
@@ -337,10 +430,14 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
delete_after_run: formDeleteAfterRun,
|
||||
run_at: runAt,
|
||||
target_id: (actionDef.requiresStack || actionDef.requiresContainer) ? formTargetId : null,
|
||||
node_id: actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null,
|
||||
node_id: isLabelUpdate
|
||||
? (formLabelScope === 'node' && formNodeId ? parseInt(formNodeId, 10) : null)
|
||||
: (actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null),
|
||||
prune_targets: formAction === 'prune' && formPruneTargets.length > 0 ? formPruneTargets : null,
|
||||
target_services: actionDef.supportsServiceSelection && formTargetServices.length > 0 ? formTargetServices : null,
|
||||
prune_label_filter: formAction === 'prune' && formPruneLabelFilter.trim() ? formPruneLabelFilter.trim() : null,
|
||||
selector_type: isLabelUpdate ? 'stack-label' : null,
|
||||
selector_value: isLabelUpdate ? formSelectorValue.trim() : null,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
@@ -515,7 +612,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|
||||
|| (!!currentAction?.requiresContainer && (!formTargetId || !formNodeId))
|
||||
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && !formNodeId)
|
||||
|| (formAction === 'prune' && formPruneTargets.length === 0);
|
||||
|| (formAction === 'prune' && formPruneTargets.length === 0)
|
||||
|| (formAction === 'update-by-label' && (
|
||||
!formSelectorValue.trim()
|
||||
|| (formLabelScope === 'node' && !formNodeId)
|
||||
));
|
||||
|
||||
const windowEnd = now + TIMELINE_WINDOW_MS;
|
||||
const timelinePills = filteredTasks
|
||||
@@ -738,15 +839,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{task.target_type === 'stack'
|
||||
? task.target_services
|
||||
? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})`
|
||||
: task.target_id
|
||||
: task.target_type === 'container'
|
||||
? task.target_id
|
||||
: task.action === 'update'
|
||||
? 'All eligible stacks'
|
||||
: task.target_type}
|
||||
{task.selector_type === 'stack-label' && task.selector_value
|
||||
? scheduleTargetDescriptor(
|
||||
task,
|
||||
task.node_id != null ? nodes.find(n => n.id === task.node_id)?.name : undefined,
|
||||
)
|
||||
: task.target_type === 'stack'
|
||||
? task.target_services
|
||||
? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})`
|
||||
: task.target_id
|
||||
: task.target_type === 'container'
|
||||
? task.target_id
|
||||
: task.action === 'update'
|
||||
? 'All eligible stacks'
|
||||
: task.target_type}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">{getCronDescription(task.cron_expression)}</div>
|
||||
@@ -855,7 +961,16 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<Combobox
|
||||
options={actionOptions}
|
||||
value={formAction}
|
||||
onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}
|
||||
onValueChange={(val) => {
|
||||
setFormAction(val);
|
||||
setFormTargetId('');
|
||||
setFormNodeId('');
|
||||
setFormTargetServices([]);
|
||||
setFormPruneLabelFilter('');
|
||||
setFormSelectorValue('');
|
||||
setFormLabelScope('fleet');
|
||||
setLabelPreview({ kind: 'idle' });
|
||||
}}
|
||||
placeholder="Select action..."
|
||||
/>
|
||||
{currentAction && (
|
||||
@@ -960,6 +1075,94 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formAction === 'update-by-label' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Stack Label</Label>
|
||||
<LabelNameAutocomplete
|
||||
id="schedule-label-input"
|
||||
value={formSelectorValue}
|
||||
onChange={setFormSelectorValue}
|
||||
suggestions={labelSuggestions}
|
||||
placeholder="Select or type a label name..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label>Scope</Label>
|
||||
<SegmentedControl<'fleet' | 'node'>
|
||||
value={formLabelScope}
|
||||
options={[
|
||||
{ value: 'fleet', label: 'Entire fleet' },
|
||||
{ value: 'node', label: 'Selected node' },
|
||||
]}
|
||||
onChange={(v) => {
|
||||
setFormLabelScope(v);
|
||||
if (v === 'fleet') setFormNodeId('');
|
||||
}}
|
||||
ariaLabel="Label update scope"
|
||||
/>
|
||||
</div>
|
||||
{formLabelScope === 'node' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodeOptions}
|
||||
value={formNodeId}
|
||||
onValueChange={setFormNodeId}
|
||||
placeholder="Select node..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border border-glass-border bg-input/40 px-3 py-2 space-y-1.5">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-stat-subtitle">Current matches</div>
|
||||
{labelPreview.kind === 'idle' && (
|
||||
<p className="text-xs text-muted-foreground">Enter a label name to preview matching stacks.</p>
|
||||
)}
|
||||
{labelPreview.kind === 'loading' && (
|
||||
<p className="text-xs text-muted-foreground">Resolving label membership...</p>
|
||||
)}
|
||||
{labelPreview.kind === 'unavailable' && (
|
||||
<p className="text-xs text-warning">Preview unavailable. You can still save; membership is resolved at run time.</p>
|
||||
)}
|
||||
{labelPreview.kind === 'ready' && (() => {
|
||||
const scoped = formLabelScope === 'node' && formNodeId
|
||||
? {
|
||||
...labelPreview.data,
|
||||
perNode: labelPreview.data.perNode.filter(n => String(n.nodeId) === formNodeId),
|
||||
}
|
||||
: labelPreview.data;
|
||||
const matchedStacks = scoped.perNode
|
||||
.filter(n => n.reachable)
|
||||
.reduce((sum, n) => sum + n.stackCount, 0);
|
||||
const reachableNodes = scoped.perNode.filter(n => n.reachable && n.stackCount > 0).length;
|
||||
const unreachable = scoped.perNode.filter(n => !n.reachable);
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-foreground">
|
||||
{matchedStacks} stack{matchedStacks === 1 ? '' : 's'} on {reachableNodes} node{reachableNodes === 1 ? '' : 's'}
|
||||
{unreachable.length > 0 ? ` · ${unreachable.length} unreachable` : ''}
|
||||
</p>
|
||||
{matchedStacks === 0 && (
|
||||
<p className="text-xs text-warning">
|
||||
No stacks currently match this label. You can still save; membership is resolved at each run.
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-1 max-h-28 overflow-y-auto">
|
||||
{scoped.perNode.map(n => (
|
||||
<li key={n.nodeId} className="text-[11px] font-mono text-stat-subtitle">
|
||||
{n.nodeName}: {n.reachable
|
||||
? (n.stackCount > 0 ? n.stackNames.slice(0, 8).join(', ') + (n.stackNames.length > 8 ? '…' : '') : 'no match')
|
||||
: `unreachable${n.error ? ` (${n.error})` : ''}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && (
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export interface LabelNameSuggestion {
|
||||
name: string;
|
||||
scope: 'stack';
|
||||
nodeCount: number;
|
||||
stackCount: number;
|
||||
nodes?: string[];
|
||||
}
|
||||
|
||||
interface LabelNameAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
suggestions: LabelNameSuggestion[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-form label-name input with a suggestion popover. Used by Scheduled
|
||||
* Operations label targeting; the operator may type a name that is not
|
||||
* suggested (membership is resolved at preview/run time).
|
||||
*/
|
||||
export function LabelNameAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
suggestions,
|
||||
disabled,
|
||||
placeholder,
|
||||
id = 'label-name-input',
|
||||
}: LabelNameAutocompleteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = value.trim().toLowerCase();
|
||||
if (q.length === 0) return suggestions;
|
||||
return suggestions.filter(s => s.name.toLowerCase().includes(q));
|
||||
}, [value, suggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onClick);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClick);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
if (!open) setOpen(true);
|
||||
}}
|
||||
onFocus={() => { if (!disabled) setOpen(true); }}
|
||||
placeholder={placeholder}
|
||||
className="h-9 text-sm"
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute left-0 top-full mt-1 z-50 w-full rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]">
|
||||
<ul className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
|
||||
{filtered.map((s) => {
|
||||
const nodes = s.nodes ?? [];
|
||||
return (
|
||||
<li key={s.name}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); onChange(s.name); setOpen(false); }}
|
||||
title={nodes.join(', ')}
|
||||
className="flex w-full flex-col gap-0.5 rounded-sm px-2 py-1.5 font-mono text-xs text-stat-value hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<span className="flex-1 min-w-0 truncate text-left">{s.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-stat-subtitle">
|
||||
{s.stackCount} stack{s.stackCount === 1 ? '' : 's'} · {s.nodeCount} node{s.nodeCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</span>
|
||||
{nodes.length > 0 && (
|
||||
<span className="w-full truncate text-left text-[10px] text-stat-icon">{nodes.join(', ')}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,21 @@ describe('scheduledActions registry', () => {
|
||||
});
|
||||
|
||||
describe('resolveTaskAction', () => {
|
||||
it('maps update + fleet + stack-label selector to update-by-label', () => {
|
||||
const def = resolveTaskAction({
|
||||
action: 'update',
|
||||
target_type: 'fleet',
|
||||
selector_type: 'stack-label',
|
||||
});
|
||||
expect(def?.id).toBe('update-by-label');
|
||||
expect(def?.backendAction).toBe('update');
|
||||
});
|
||||
|
||||
it('maps update + fleet without selector to update-fleet', () => {
|
||||
const def = resolveTaskAction({ action: 'update', target_type: 'fleet', selector_type: null });
|
||||
expect(def?.id).toBe('update-fleet');
|
||||
});
|
||||
|
||||
it('maps update + fleet to the update-fleet UI entry', () => {
|
||||
const def = resolveTaskAction({ action: 'update', target_type: 'fleet' });
|
||||
expect(def?.id).toBe('update-fleet');
|
||||
@@ -116,7 +131,7 @@ describe('scheduledActions registry', () => {
|
||||
expect(ids).toEqual([
|
||||
'auto_backup', 'auto_start', 'restart', 'auto_stop', 'auto_down',
|
||||
'container-restart', 'container-stop', 'container-start',
|
||||
'update', 'update-fleet',
|
||||
'update', 'update-fleet', 'update-by-label',
|
||||
'scan',
|
||||
'prune',
|
||||
'snapshot',
|
||||
@@ -130,6 +145,14 @@ describe('scheduledActions registry', () => {
|
||||
expect(fleetUpdate!.targetType).toBe('fleet');
|
||||
});
|
||||
|
||||
it('preserves the update-by-label alias', () => {
|
||||
const byLabel = SCHEDULED_ACTIONS.find(a => a.id === 'update-by-label');
|
||||
expect(byLabel).toBeDefined();
|
||||
expect(byLabel!.backendAction).toBe('update');
|
||||
expect(byLabel!.targetType).toBe('fleet');
|
||||
expect(byLabel!.requiresNode).toBe(false);
|
||||
});
|
||||
|
||||
describe('helperText', () => {
|
||||
const expected: Record<string, string> = {
|
||||
'auto_backup': 'Backs up compose and env files only. This does not back up application volumes.',
|
||||
@@ -142,6 +165,7 @@ describe('scheduledActions registry', () => {
|
||||
'container-start': 'Starts a stopped container by name on the selected node.',
|
||||
'update': "Checks this stack's images and recreates the stack only when newer images are available.",
|
||||
'update-fleet': 'Checks every stack on the selected node and updates stacks with newer images.',
|
||||
'update-by-label': 'Resolves stacks that currently carry a Stack Label at each run, across the entire fleet or one node, and updates those with newer images.',
|
||||
'scan': 'Runs Trivy against images on the selected local node and records the findings.',
|
||||
'prune': 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.',
|
||||
'snapshot': 'Creates a versioned snapshot of compose and env files across the fleet.',
|
||||
@@ -166,6 +190,7 @@ describe('scheduledActions registry', () => {
|
||||
'container-start': 'runtime-change',
|
||||
'update': 'runtime-change',
|
||||
'update-fleet': 'runtime-change',
|
||||
'update-by-label': 'runtime-change',
|
||||
'scan': 'read-only',
|
||||
'prune': 'destructive',
|
||||
'snapshot': 'safe',
|
||||
@@ -238,6 +263,22 @@ describe('scheduledActions registry', () => {
|
||||
expect(scheduleTargetDescriptor(task)).toBe('All stacks');
|
||||
});
|
||||
|
||||
it('shows Label: name · Entire fleet or node for stack-label selectors', () => {
|
||||
const fleet = {
|
||||
action: 'update' as const,
|
||||
target_type: 'fleet' as const,
|
||||
target_id: null,
|
||||
name: 'Label update',
|
||||
selector_type: 'stack-label',
|
||||
selector_value: 'Production',
|
||||
node_id: null as number | null,
|
||||
};
|
||||
expect(scheduleTargetDescriptor(fleet)).toBe('Label: Production · Entire fleet');
|
||||
const scoped = { ...fleet, node_id: 7 };
|
||||
expect(scheduleTargetDescriptor(scoped, 'Node A')).toBe('Label: Production · Node A');
|
||||
expect(scheduleTargetDescriptor(scoped)).toBe('Label: Production · node 7');
|
||||
});
|
||||
|
||||
it('shows Entire fleet for a fleet snapshot regardless of node', () => {
|
||||
const task: TargetTask = { action: 'snapshot', target_type: 'fleet', target_id: null, name: 'Nightly Snapshot' };
|
||||
expect(scheduleTargetDescriptor(task, 'edge-1')).toBe('Entire fleet');
|
||||
|
||||
@@ -19,7 +19,7 @@ export type BackendAction = ScheduledTask['action'];
|
||||
* UI action ids. `update-fleet` is a frontend-only alias for `update` with
|
||||
* `target_type: 'fleet'`; it never reaches the backend.
|
||||
*/
|
||||
export type ScheduledActionId = BackendAction | 'update-fleet' | 'container-restart' | 'container-stop' | 'container-start';
|
||||
export type ScheduledActionId = BackendAction | 'update-fleet' | 'update-by-label' | 'container-restart' | 'container-stop' | 'container-start';
|
||||
|
||||
export type ScheduledActionCategory = 'lifecycle' | 'updates' | 'security' | 'maintenance' | 'backups';
|
||||
export type ScheduledActionTone = 'success' | 'warning' | 'destructive' | 'brand';
|
||||
@@ -105,6 +105,7 @@ export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [
|
||||
// Updates
|
||||
{ id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' },
|
||||
{ id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' },
|
||||
{ id: 'update-by-label', backendAction: 'update', label: 'Auto-update stacks by label', shortLabel: 'update label', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Resolves stacks that currently carry a Stack Label at each run, across the entire fleet or one node, and updates those with newer images.', riskLevel: 'runtime-change' },
|
||||
// Security
|
||||
{ id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' },
|
||||
// Maintenance
|
||||
@@ -121,12 +122,15 @@ export function getActionById(id: string): ScheduledActionDefinition | undefined
|
||||
|
||||
/**
|
||||
* Resolve a stored task to its action definition. A stored `update` task with a
|
||||
* `fleet` target maps to the `update-fleet` UI entry; everything else maps by
|
||||
* its backend action id.
|
||||
* stack-label selector maps to `update-by-label`; a plain fleet update maps to
|
||||
* `update-fleet`; everything else maps by its backend action id.
|
||||
*/
|
||||
export function resolveTaskAction(
|
||||
task: Pick<ScheduledTask, 'action' | 'target_type'>,
|
||||
task: Pick<ScheduledTask, 'action' | 'target_type'> & { selector_type?: string | null },
|
||||
): ScheduledActionDefinition | undefined {
|
||||
if (task.action === 'update' && task.target_type === 'fleet' && task.selector_type === 'stack-label') {
|
||||
return getActionById('update-by-label');
|
||||
}
|
||||
if (task.action === 'update' && task.target_type === 'fleet') {
|
||||
return getActionById('update-fleet');
|
||||
}
|
||||
@@ -147,12 +151,23 @@ export function stripComposeExt(name: string): string {
|
||||
* Category-aware label for what a scheduled run acts on, used by the Timeline
|
||||
* pills and the mobile schedule list. Stack actions show the stack, fleet
|
||||
* snapshots show the whole fleet, fleet updates and node-scoped actions
|
||||
* (prune / scan) show the selected node when its name is known.
|
||||
* (prune / scan) show the selected node when its name is known. Label-targeted
|
||||
* updates show the label name and fleet or node scope.
|
||||
*/
|
||||
export function scheduleTargetDescriptor(
|
||||
task: Pick<ScheduledTask, 'action' | 'target_type' | 'target_id' | 'name'>,
|
||||
task: Pick<ScheduledTask, 'action' | 'target_type' | 'target_id' | 'name'> & {
|
||||
selector_type?: string | null;
|
||||
selector_value?: string | null;
|
||||
node_id?: number | null;
|
||||
},
|
||||
nodeName?: string,
|
||||
): string {
|
||||
if (task.selector_type === 'stack-label' && task.selector_value) {
|
||||
if (task.node_id != null) {
|
||||
return `Label: ${task.selector_value} · ${nodeName ?? `node ${task.node_id}`}`;
|
||||
}
|
||||
return `Label: ${task.selector_value} · Entire fleet`;
|
||||
}
|
||||
switch (task.target_type) {
|
||||
case 'stack':
|
||||
return stripComposeExt(task.target_id ?? task.name);
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ScheduledTask {
|
||||
prune_targets: string | null;
|
||||
target_services: string | null;
|
||||
prune_label_filter: string | null;
|
||||
selector_type?: string | null;
|
||||
selector_value?: string | null;
|
||||
delete_after_run?: number;
|
||||
// Absolute epoch-ms fire time for a one-time ('once') schedule; null/absent for
|
||||
// recurring shapes. Persisted so the chosen instant (including year) survives
|
||||
|
||||
Reference in New Issue
Block a user