mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
feat(scheduler): consistent action targeting in Scheduled Operations (#1431)
Give every scheduled action an explicit, predictable target model (Action then Node then Stack then Options then Schedule): - System Prune now exposes a Node picker and requires a node, so it can no longer run silently on the default node. - Vulnerability Scan and System Prune list local nodes only; both run on the hub-local Docker daemon and reject remote nodes on the backend. - Restart Stack service discovery loads services from the selected node via fetchForNode instead of the active or local node. - Fleet Snapshot shows a read-only "Scope: Entire fleet" summary. Backend gains a shared local-node guard and prune node validation on create and update, plus an executor-level remote-node guard, so the frontend and backend validation now agree for every action.
This commit is contained in:
@@ -24,6 +24,10 @@ vi.mock('../services/NodeRegistry', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/debug', () => ({
|
||||||
|
isDebugEnabled: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
import { FileSystemService } from '../services/FileSystemService';
|
import { FileSystemService } from '../services/FileSystemService';
|
||||||
|
|
||||||
describe('FileSystemService backup location', () => {
|
describe('FileSystemService backup location', () => {
|
||||||
@@ -85,6 +89,28 @@ describe('FileSystemService backup location', () => {
|
|||||||
expect(typeof after.timestamp).toBe('number');
|
expect(typeof after.timestamp).toBe('number');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('aborts backup creation when a destination write fails', async () => {
|
||||||
|
const stackName = 'writefail';
|
||||||
|
const stackDir = path.join(composeDir, stackName);
|
||||||
|
const backupCompose = path.join(dataDir, 'backups', '1', stackName, 'compose.yaml');
|
||||||
|
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||||
|
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n', 'utf-8');
|
||||||
|
|
||||||
|
const realWriteFile = fsPromises.writeFile.bind(fsPromises);
|
||||||
|
const writeSpy = vi.spyOn(fsPromises, 'writeFile').mockImplementation(async (file, data, options) => {
|
||||||
|
if (path.normalize(String(file)) === path.normalize(backupCompose)) {
|
||||||
|
throw new Error('disk full');
|
||||||
|
}
|
||||||
|
return realWriteFile(file, data, options);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(FileSystemService.getInstance().backupStackFiles(stackName)).rejects.toThrow(/Could not write backup compose.yaml/);
|
||||||
|
await expect(fsPromises.access(backupCompose)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
} finally {
|
||||||
|
writeSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('scopes backups by node id when stack names overlap', async () => {
|
it('scopes backups by node id when stack names overlap', async () => {
|
||||||
const stackName = 'web';
|
const stackName = 'web';
|
||||||
const secondComposeDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'sencho-compose-'));
|
const secondComposeDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'sencho-compose-'));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
VALID_ACTIONS,
|
VALID_ACTIONS,
|
||||||
BACKEND_SCHEDULED_ACTIONS,
|
BACKEND_SCHEDULED_ACTIONS,
|
||||||
INVALID_ACTION_MESSAGE,
|
INVALID_ACTION_MESSAGE,
|
||||||
|
getScheduledActionDefinition,
|
||||||
validateActionTarget,
|
validateActionTarget,
|
||||||
type BackendScheduledAction,
|
type BackendScheduledAction,
|
||||||
type TargetType,
|
type TargetType,
|
||||||
@@ -36,6 +37,13 @@ describe('scheduledActionRegistry', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks node-scoped and local-only actions in backend metadata', () => {
|
||||||
|
expect(getScheduledActionDefinition('scan')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
|
||||||
|
expect(getScheduledActionDefinition('prune')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
|
||||||
|
expect(getScheduledActionDefinition('update')).toMatchObject({ requiresNode: true });
|
||||||
|
expect(getScheduledActionDefinition('snapshot')).toMatchObject({ requiresNode: false });
|
||||||
|
});
|
||||||
|
|
||||||
describe('validateActionTarget', () => {
|
describe('validateActionTarget', () => {
|
||||||
const validPairs: Record<BackendScheduledAction, TargetType[]> = {
|
const validPairs: Record<BackendScheduledAction, TargetType[]> = {
|
||||||
restart: ['stack'],
|
restart: ['stack'],
|
||||||
|
|||||||
@@ -215,6 +215,34 @@ describe('POST /api/scheduled-tasks', () => {
|
|||||||
expect(res.body.error).toMatch(/Scan action requires node_id/);
|
expect(res.body.error).toMatch(/Scan action requires node_id/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const badNodeId of [true, '1.5', 0]) {
|
||||||
|
it(`rejects scan with malformed node_id ${JSON.stringify(badNodeId)}`, async () => {
|
||||||
|
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||||
|
name: 'bad-scan-node',
|
||||||
|
target_type: 'system',
|
||||||
|
node_id: badNodeId,
|
||||||
|
action: 'scan',
|
||||||
|
cron_expression: '0 0 * * *',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/valid node_id|node_id/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const badNodeId of [true, '1.5', 0]) {
|
||||||
|
it(`rejects fleet update with malformed node_id ${JSON.stringify(badNodeId)}`, async () => {
|
||||||
|
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||||
|
name: 'bad-fleet-update-node',
|
||||||
|
target_type: 'fleet',
|
||||||
|
node_id: badNodeId,
|
||||||
|
action: 'update',
|
||||||
|
cron_expression: '0 0 * * *',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/valid node_id|node_id/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('rejects scheduled scans on remote nodes', async () => {
|
it('rejects scheduled scans on remote nodes', async () => {
|
||||||
const remoteNodeId = DatabaseService.getInstance().addNode({
|
const remoteNodeId = DatabaseService.getInstance().addNode({
|
||||||
name: 'remote-scan-node',
|
name: 'remote-scan-node',
|
||||||
@@ -236,6 +264,45 @@ describe('POST /api/scheduled-tasks', () => {
|
|||||||
expect(res.body.error).toMatch(/local node/i);
|
expect(res.body.error).toMatch(/local node/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects prune without node_id', async () => {
|
||||||
|
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||||
|
name: 'cleanup', target_type: 'system', action: 'prune', cron_expression: '0 4 * * *',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Prune action requires node_id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects scheduled prunes on remote nodes', async () => {
|
||||||
|
const remoteNodeId = DatabaseService.getInstance().addNode({
|
||||||
|
name: 'remote-prune-node',
|
||||||
|
type: 'remote',
|
||||||
|
api_url: 'http://remote.local:1852',
|
||||||
|
api_token: 'token',
|
||||||
|
compose_dir: '/srv/compose',
|
||||||
|
is_default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||||
|
name: 'remote-prune',
|
||||||
|
target_type: 'system',
|
||||||
|
node_id: remoteNodeId,
|
||||||
|
action: 'prune',
|
||||||
|
cron_expression: '0 4 * * *',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/local node/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a prune task on a local node', async () => {
|
||||||
|
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||||
|
name: 'local-prune', target_type: 'system', node_id: 1, action: 'prune',
|
||||||
|
cron_expression: '0 4 * * *', enabled: true,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.action).toBe('prune');
|
||||||
|
expect(res.body.node_id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects target_services with wrong action', async () => {
|
it('rejects target_services with wrong action', async () => {
|
||||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||||
...basePayload, action: 'update', target_services: ['web'],
|
...basePayload, action: 'update', target_services: ['web'],
|
||||||
@@ -528,6 +595,48 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
|||||||
expect(res.body.error).toMatch(/node_id/);
|
expect(res.body.error).toMatch(/node_id/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects updates that clear node_id on a prune task', async () => {
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
const now = Date.now();
|
||||||
|
const pruneId = db.createScheduledTask({
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/scheduled-tasks/${pruneId}`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ node_id: null });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Prune action requires node_id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects updates that point a prune task at a remote node', async () => {
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
const now = Date.now();
|
||||||
|
const pruneId = db.createScheduledTask({
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
const remoteNodeId = db.addNode({
|
||||||
|
name: 'remote-prune-update-node', type: 'remote', api_url: 'http://remote.local:1852',
|
||||||
|
api_token: 'token', compose_dir: '/srv/compose', is_default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/scheduled-tasks/${pruneId}`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ node_id: remoteNodeId });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/local node/i);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects updates that clear target_type', async () => {
|
it('rejects updates that clear target_type', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put(`/api/scheduled-tasks/${taskId}`)
|
.put(`/api/scheduled-tasks/${taskId}`)
|
||||||
@@ -547,6 +656,45 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.error).toMatch(/Invalid action/);
|
expect(res.body.error).toMatch(/Invalid action/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears stale stack and service fields when changing a task to a fleet snapshot', async () => {
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
const now = Date.now();
|
||||||
|
const restartId = db.createScheduledTask({
|
||||||
|
name: 'restart',
|
||||||
|
target_type: 'stack',
|
||||||
|
target_id: 'web',
|
||||||
|
node_id: 1,
|
||||||
|
action: 'restart',
|
||||||
|
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: JSON.stringify(['api']),
|
||||||
|
prune_label_filter: null,
|
||||||
|
delete_after_run: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/scheduled-tasks/${restartId}`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ action: 'snapshot', target_type: 'fleet' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.action).toBe('snapshot');
|
||||||
|
expect(res.body.target_type).toBe('fleet');
|
||||||
|
expect(res.body.target_id).toBeNull();
|
||||||
|
expect(res.body.node_id).toBeNull();
|
||||||
|
expect(res.body.target_services).toBeNull();
|
||||||
|
expect(res.body.prune_targets).toBeNull();
|
||||||
|
expect(res.body.prune_label_filter).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('scheduled-tasks state-invalidate broadcast', () => {
|
describe('scheduled-tasks state-invalidate broadcast', () => {
|
||||||
|
|||||||
@@ -594,6 +594,32 @@ describe('SchedulerService - executePrune', () => {
|
|||||||
|
|
||||||
expect(mockPruneSystem).toHaveBeenCalledWith('containers', 'env=staging');
|
expect(mockPruneSystem).toHaveBeenCalledWith('containers', 'env=staging');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails scheduled prune tasks that target remote nodes before pruning', async () => {
|
||||||
|
mockGetScheduledTask.mockReturnValue({
|
||||||
|
id: 73,
|
||||||
|
name: 'remote-prune',
|
||||||
|
action: 'prune',
|
||||||
|
cron_expression: '0 3 * * *',
|
||||||
|
enabled: true,
|
||||||
|
node_id: 2,
|
||||||
|
created_by: 'admin',
|
||||||
|
last_status: null,
|
||||||
|
});
|
||||||
|
mockGetNode.mockReturnValue({ id: 2, name: 'remote', type: 'remote', status: 'online' });
|
||||||
|
|
||||||
|
const svc = SchedulerService.getInstance();
|
||||||
|
await svc.triggerTask(73);
|
||||||
|
|
||||||
|
expect(mockPruneSystem).not.toHaveBeenCalled();
|
||||||
|
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
|
||||||
|
expect.any(Number),
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'failure',
|
||||||
|
error: expect.stringMatching(/local node/i),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── executeUpdate ──────────────────────────────────────────────────────
|
// ── executeUpdate ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
VALID_ACTIONS,
|
VALID_ACTIONS,
|
||||||
INVALID_ACTION_MESSAGE,
|
INVALID_ACTION_MESSAGE,
|
||||||
validateActionTarget,
|
validateActionTarget,
|
||||||
|
getScheduledActionDefinition,
|
||||||
type TargetType,
|
type TargetType,
|
||||||
type BackendScheduledAction,
|
type BackendScheduledAction,
|
||||||
} from '../services/scheduledActionRegistry';
|
} from '../services/scheduledActionRegistry';
|
||||||
@@ -35,6 +36,31 @@ function broadcastScheduledTasksChanged(): void {
|
|||||||
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
|
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
|
||||||
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
|
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
|
||||||
|
|
||||||
|
function parsePositiveNodeId(nodeId: unknown): number | null {
|
||||||
|
if (typeof nodeId !== 'number' && typeof nodeId !== 'string') return null;
|
||||||
|
if (typeof nodeId === 'string' && nodeId.trim().length === 0) return null;
|
||||||
|
const parsedNodeId = Number(nodeId);
|
||||||
|
return Number.isInteger(parsedNodeId) && parsedNodeId > 0 ? parsedNodeId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionRequiresNode(action: BackendScheduledAction, targetType: TargetType): boolean {
|
||||||
|
if (targetType === 'stack') return true;
|
||||||
|
return getScheduledActionDefinition(action)?.requiresNode === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeRequirementLabel(action: BackendScheduledAction, targetType: TargetType): string {
|
||||||
|
if (action === 'scan') return 'Scan';
|
||||||
|
if (action === 'prune') return 'Prune';
|
||||||
|
if (action === 'update' && targetType === 'fleet') return 'Fleet update';
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localNodeRequirementLabel(action: BackendScheduledAction): string {
|
||||||
|
if (action === 'scan') return 'Scheduled vulnerability scans';
|
||||||
|
if (action === 'prune') return 'Scheduled prunes';
|
||||||
|
return `${action} tasks`;
|
||||||
|
}
|
||||||
|
|
||||||
function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null {
|
function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null {
|
||||||
if (targetType !== 'stack') return null;
|
if (targetType !== 'stack') return null;
|
||||||
|
|
||||||
@@ -46,23 +72,38 @@ function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId:
|
|||||||
return 'Stack target_id must be a valid stack name.';
|
return 'Stack target_id must be a valid stack name.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedNodeId = Number(nodeId);
|
if (parsePositiveNodeId(nodeId) === null) {
|
||||||
if (!Number.isInteger(parsedNodeId) || parsedNodeId <= 0) {
|
|
||||||
return 'Stack operations require a valid node_id.';
|
return 'Stack operations require a valid node_id.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateScanNode(nodeId: unknown): string | null {
|
/**
|
||||||
if (nodeId == null) return 'Scan action requires node_id.';
|
* Shared guard for non-stack actions that require a node. Stack actions use
|
||||||
const parsedNodeId = Number(nodeId);
|
* validateStackTarget because they also require target_id.
|
||||||
if (!Number.isFinite(parsedNodeId)) return 'Scan action requires a valid node_id.';
|
*/
|
||||||
const node = DatabaseService.getInstance().getNode(parsedNodeId);
|
function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null {
|
||||||
if (!node) return 'Scheduled vulnerability scans require an existing local node.';
|
if (targetType === 'stack') return null;
|
||||||
if (node?.type === 'remote') {
|
const def = getScheduledActionDefinition(action);
|
||||||
return 'Scheduled vulnerability scans currently require a local node.';
|
if (!def?.requiresNode) return null;
|
||||||
|
|
||||||
|
const labelSingular = nodeRequirementLabel(action, targetType);
|
||||||
|
const labelPlural = localNodeRequirementLabel(action);
|
||||||
|
|
||||||
|
if (nodeId == null) {
|
||||||
|
return action === 'update' && targetType === 'fleet'
|
||||||
|
? ERR_FLEET_NODE_REQUIRED
|
||||||
|
: `${labelSingular} action requires node_id.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedNodeId = parsePositiveNodeId(nodeId);
|
||||||
|
if (parsedNodeId === null) return `${labelSingular} action requires a valid node_id.`;
|
||||||
|
if (def.nodeScope !== 'local') return null;
|
||||||
|
|
||||||
|
const node = DatabaseService.getInstance().getNode(parsedNodeId);
|
||||||
|
if (!node) return `${labelPlural} require an existing local node.`;
|
||||||
|
if (node.type === 'remote') return `${labelPlural} currently require a local node.`;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,16 +194,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
|||||||
const targetErr = validateActionTarget(action, target_type);
|
const targetErr = validateActionTarget(action, target_type);
|
||||||
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
||||||
|
|
||||||
if (action === 'scan' && !node_id) {
|
const nodeErr = validateActionNode(action, target_type, node_id);
|
||||||
res.status(400).json({ error: 'Scan action requires node_id.' }); return;
|
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
||||||
}
|
|
||||||
if (action === 'scan') {
|
|
||||||
const nodeErr = validateScanNode(node_id);
|
|
||||||
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
|
||||||
}
|
|
||||||
if (action === 'update' && target_type === 'fleet' && !node_id) {
|
|
||||||
res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return;
|
|
||||||
}
|
|
||||||
const stackTargetErr = validateStackTarget(target_type, target_id, node_id);
|
const stackTargetErr = validateStackTarget(target_type, target_id, node_id);
|
||||||
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
|
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
|
||||||
|
|
||||||
@@ -177,12 +210,14 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
|||||||
const scheduler = SchedulerService.getInstance();
|
const scheduler = SchedulerService.getInstance();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const nextRun = (enabled !== false) ? scheduler.calculateNextRun(cron_expression) : null;
|
const nextRun = (enabled !== false) ? scheduler.calculateNextRun(cron_expression) : null;
|
||||||
|
const normalizedTargetId = target_type === 'stack' ? target_id : null;
|
||||||
|
const normalizedNodeId = actionRequiresNode(action, target_type) ? parsePositiveNodeId(node_id) : null;
|
||||||
|
|
||||||
const id = DatabaseService.getInstance().createScheduledTask({
|
const id = DatabaseService.getInstance().createScheduledTask({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
target_type,
|
target_type,
|
||||||
target_id: target_id || null,
|
target_id: normalizedTargetId,
|
||||||
node_id: node_id != null ? Number(node_id) : null,
|
node_id: normalizedNodeId,
|
||||||
action,
|
action,
|
||||||
cron_expression,
|
cron_expression,
|
||||||
enabled: enabled !== false ? 1 : 0,
|
enabled: enabled !== false ? 1 : 0,
|
||||||
@@ -193,9 +228,9 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
|||||||
next_run_at: nextRun,
|
next_run_at: nextRun,
|
||||||
last_status: null,
|
last_status: null,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
prune_targets: prune_targets ? JSON.stringify(prune_targets) : null,
|
prune_targets: action === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null,
|
||||||
target_services: target_services ? JSON.stringify(target_services) : null,
|
target_services: action === 'restart' && target_type === 'stack' && target_services ? JSON.stringify(target_services) : null,
|
||||||
prune_label_filter: prune_label_filter ? prune_label_filter.trim() : null,
|
prune_label_filter: action === 'prune' && prune_label_filter ? prune_label_filter.trim() : null,
|
||||||
delete_after_run: delete_after_run ? 1 : 0,
|
delete_after_run: delete_after_run ? 1 : 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,20 +279,17 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
|||||||
|
|
||||||
const finalAction = (action ?? existing.action) as BackendScheduledAction;
|
const finalAction = (action ?? existing.action) as BackendScheduledAction;
|
||||||
const finalTargetType = (target_type ?? existing.target_type) as TargetType;
|
const finalTargetType = (target_type ?? existing.target_type) as TargetType;
|
||||||
const finalTargetId = target_id !== undefined ? target_id : existing.target_id;
|
const finalTargetId = finalTargetType === 'stack'
|
||||||
const finalNodeId = node_id !== undefined ? node_id : existing.node_id;
|
? (target_id !== undefined ? target_id : existing.target_id)
|
||||||
|
: null;
|
||||||
|
const finalNodeId = actionRequiresNode(finalAction, finalTargetType)
|
||||||
|
? (node_id !== undefined ? node_id : existing.node_id)
|
||||||
|
: null;
|
||||||
const targetErr = validateActionTarget(finalAction, finalTargetType);
|
const targetErr = validateActionTarget(finalAction, finalTargetType);
|
||||||
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
||||||
|
|
||||||
if (finalAction === 'scan') {
|
const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId);
|
||||||
const nodeErr = validateScanNode(finalNodeId);
|
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
||||||
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
|
||||||
}
|
|
||||||
if (finalAction === 'update' && finalTargetType === 'fleet') {
|
|
||||||
if (!finalNodeId) {
|
|
||||||
res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId);
|
const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId);
|
||||||
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
|
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
|
||||||
@@ -274,15 +306,31 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
|||||||
|
|
||||||
const updates: Record<string, unknown> = { updated_at: Date.now() };
|
const updates: Record<string, unknown> = { updated_at: Date.now() };
|
||||||
if (name !== undefined) updates.name = typeof name === 'string' ? name.trim() : name;
|
if (name !== undefined) updates.name = typeof name === 'string' ? name.trim() : name;
|
||||||
if (target_type !== undefined) updates.target_type = target_type;
|
if (target_type !== undefined) updates.target_type = finalTargetType;
|
||||||
if (target_id !== undefined) updates.target_id = target_id || null;
|
if (target_id !== undefined || finalTargetType !== 'stack') updates.target_id = finalTargetId || null;
|
||||||
if (node_id !== undefined) updates.node_id = node_id != null ? Number(node_id) : null;
|
if (node_id !== undefined || !actionRequiresNode(finalAction, finalTargetType)) {
|
||||||
if (action !== undefined) updates.action = action;
|
updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null;
|
||||||
|
}
|
||||||
|
if (action !== undefined) updates.action = finalAction;
|
||||||
if (cron_expression !== undefined) updates.cron_expression = cron_expression;
|
if (cron_expression !== undefined) updates.cron_expression = cron_expression;
|
||||||
if (enabled !== undefined) updates.enabled = enabled ? 1 : 0;
|
if (enabled !== undefined) updates.enabled = enabled ? 1 : 0;
|
||||||
if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null;
|
if (prune_targets !== undefined) {
|
||||||
if (target_services !== undefined) updates.target_services = target_services ? JSON.stringify(target_services) : null;
|
updates.prune_targets = finalAction === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null;
|
||||||
if (prune_label_filter !== undefined) updates.prune_label_filter = prune_label_filter ? prune_label_filter.trim() : null;
|
} else if (finalAction !== 'prune') {
|
||||||
|
updates.prune_targets = null;
|
||||||
|
}
|
||||||
|
if (target_services !== undefined) {
|
||||||
|
updates.target_services = finalAction === 'restart' && finalTargetType === 'stack' && target_services
|
||||||
|
? JSON.stringify(target_services)
|
||||||
|
: null;
|
||||||
|
} else if (finalAction !== 'restart' || finalTargetType !== 'stack') {
|
||||||
|
updates.target_services = null;
|
||||||
|
}
|
||||||
|
if (prune_label_filter !== undefined) {
|
||||||
|
updates.prune_label_filter = finalAction === 'prune' && prune_label_filter ? prune_label_filter.trim() : null;
|
||||||
|
} else if (finalAction !== 'prune') {
|
||||||
|
updates.prune_label_filter = null;
|
||||||
|
}
|
||||||
if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0;
|
if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0;
|
||||||
|
|
||||||
const finalCron = cron_expression || existing.cron_expression;
|
const finalCron = cron_expression || existing.cron_expression;
|
||||||
|
|||||||
@@ -986,22 +986,39 @@ export class FileSystemService {
|
|||||||
// credit the barrier, which it does not through the helper.
|
// credit the barrier, which it does not through the helper.
|
||||||
const baseResolved = path.resolve(this.baseDir);
|
const baseResolved = path.resolve(this.baseDir);
|
||||||
const checksums: Record<string, string> = {};
|
const checksums: Record<string, string> = {};
|
||||||
|
const writeManagedBackupFile = async (file: string, src: string): Promise<void> => {
|
||||||
|
let buf: Buffer;
|
||||||
|
try {
|
||||||
|
buf = await fsPromises.readFile(src);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const code = (e as NodeJS.ErrnoException)?.code;
|
||||||
|
if (code !== 'ENOENT') {
|
||||||
|
console.warn(`[FileSystemService] Could not read ${file} for backup:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dest = path.join(backupDir, file);
|
||||||
|
try {
|
||||||
|
await fsPromises.writeFile(dest, buf);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
try {
|
||||||
|
await fsPromises.unlink(dest);
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup only. The write failure below is the actionable error.
|
||||||
|
}
|
||||||
|
throw new Error(`Could not write backup ${file}: ${(e as Error).message}`, { cause: e });
|
||||||
|
}
|
||||||
|
checksums[file] = sha256HexBuffer(buf);
|
||||||
|
};
|
||||||
|
|
||||||
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||||
for (const file of composeFiles) {
|
for (const file of composeFiles) {
|
||||||
const src = path.resolve(baseResolved, path.join(stackDir, file));
|
const src = path.resolve(baseResolved, path.join(stackDir, file));
|
||||||
if (!src.startsWith(baseResolved + path.sep)) {
|
if (!src.startsWith(baseResolved + path.sep)) {
|
||||||
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
||||||
}
|
}
|
||||||
try {
|
await writeManagedBackupFile(file, src);
|
||||||
const buf = await fsPromises.readFile(src);
|
|
||||||
await fsPromises.writeFile(path.join(backupDir, file), buf);
|
|
||||||
checksums[file] = sha256HexBuffer(buf);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const code = (e as NodeJS.ErrnoException)?.code;
|
|
||||||
if (code !== 'ENOENT') {
|
|
||||||
console.warn(`[FileSystemService] Could not back up ${file}:`, (e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy .env if it exists (same inline containment barrier as above).
|
// Copy .env if it exists (same inline containment barrier as above).
|
||||||
@@ -1009,16 +1026,7 @@ export class FileSystemService {
|
|||||||
if (!envSrc.startsWith(baseResolved + path.sep)) {
|
if (!envSrc.startsWith(baseResolved + path.sep)) {
|
||||||
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
||||||
}
|
}
|
||||||
try {
|
await writeManagedBackupFile('.env', envSrc);
|
||||||
const buf = await fsPromises.readFile(envSrc);
|
|
||||||
await fsPromises.writeFile(path.join(backupDir, '.env'), buf);
|
|
||||||
checksums['.env'] = sha256HexBuffer(buf);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const code = (e as NodeJS.ErrnoException)?.code;
|
|
||||||
if (code !== 'ENOENT') {
|
|
||||||
console.warn('[FileSystemService] Could not back up .env:', (e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the integrity manifest before the timestamp marker, so a crash
|
// Write the integrity manifest before the timestamp marker, so a crash
|
||||||
// between the two leaves the checksums present (a backup that restore can
|
// between the two leaves the checksums present (a backup that restore can
|
||||||
|
|||||||
@@ -662,6 +662,9 @@ export class SchedulerService {
|
|||||||
if (task.node_id == null && isDebugEnabled()) {
|
if (task.node_id == null && isDebugEnabled()) {
|
||||||
console.log(`[SchedulerService:debug] Prune task ${task.id}: no node_id specified, using default node ${nodeId}`);
|
console.log(`[SchedulerService:debug] Prune task ${task.id}: no node_id specified, using default node ${nodeId}`);
|
||||||
}
|
}
|
||||||
|
if (this.isRemoteNode(nodeId)) {
|
||||||
|
throw new Error('Scheduled prunes currently require a local node.');
|
||||||
|
}
|
||||||
const docker = DockerController.getInstance(nodeId);
|
const docker = DockerController.getInstance(nodeId);
|
||||||
const allTargets = ['containers', 'images', 'networks', 'volumes'] as const;
|
const allTargets = ['containers', 'images', 'networks', 'volumes'] as const;
|
||||||
type PruneTarget = typeof allTargets[number];
|
type PruneTarget = typeof allTargets[number];
|
||||||
|
|||||||
@@ -13,10 +13,12 @@
|
|||||||
export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
|
export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
|
||||||
export type TargetType = typeof VALID_TARGET_TYPES[number];
|
export type TargetType = typeof VALID_TARGET_TYPES[number];
|
||||||
|
|
||||||
interface BackendScheduledActionDefinition {
|
export interface BackendScheduledActionDefinition {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
/** Target types this action accepts. `update` is the only multi-target action. */
|
/** Target types this action accepts. `update` is the only multi-target action. */
|
||||||
readonly targetTypes: readonly TargetType[];
|
readonly targetTypes: readonly TargetType[];
|
||||||
|
readonly requiresNode: boolean;
|
||||||
|
readonly nodeScope?: 'local';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,15 +26,15 @@ interface BackendScheduledActionDefinition {
|
|||||||
* in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ...").
|
* in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ...").
|
||||||
*/
|
*/
|
||||||
export const BACKEND_SCHEDULED_ACTIONS = [
|
export const BACKEND_SCHEDULED_ACTIONS = [
|
||||||
{ id: 'restart', targetTypes: ['stack'] },
|
{ id: 'restart', targetTypes: ['stack'], requiresNode: true },
|
||||||
{ id: 'snapshot', targetTypes: ['fleet'] },
|
{ id: 'snapshot', targetTypes: ['fleet'], requiresNode: false },
|
||||||
{ id: 'prune', targetTypes: ['system'] },
|
{ id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' },
|
||||||
{ id: 'update', targetTypes: ['stack', 'fleet'] },
|
{ id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true },
|
||||||
{ id: 'scan', targetTypes: ['system'] },
|
{ id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' },
|
||||||
{ id: 'auto_backup', targetTypes: ['stack'] },
|
{ id: 'auto_backup', targetTypes: ['stack'], requiresNode: true },
|
||||||
{ id: 'auto_stop', targetTypes: ['stack'] },
|
{ id: 'auto_stop', targetTypes: ['stack'], requiresNode: true },
|
||||||
{ id: 'auto_down', targetTypes: ['stack'] },
|
{ id: 'auto_down', targetTypes: ['stack'], requiresNode: true },
|
||||||
{ id: 'auto_start', targetTypes: ['stack'] },
|
{ id: 'auto_start', targetTypes: ['stack'], requiresNode: true },
|
||||||
] as const satisfies readonly BackendScheduledActionDefinition[];
|
] as const satisfies readonly BackendScheduledActionDefinition[];
|
||||||
|
|
||||||
export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id'];
|
export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id'];
|
||||||
@@ -77,3 +79,7 @@ export function validateActionTarget(action: BackendScheduledAction, targetType:
|
|||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
return def.targetTypes.includes(targetType) ? null : TARGET_MISMATCH_MESSAGE[action];
|
return def.targetTypes.includes(targetType) ? null : TARGET_MISMATCH_MESSAGE[action];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getScheduledActionDefinition(action: BackendScheduledAction): BackendScheduledActionDefinition | undefined {
|
||||||
|
return ACTION_BY_ID.get(action);
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ The All tasks toggle swaps the lane track for a sortable table.
|
|||||||
| **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 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** | 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 All Stacks** | 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. |
|
||||||
| **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). |
|
| **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). |
|
||||||
| **System Prune** | The selected node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. |
|
| **System Prune** | A local node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. Runs on local nodes only. |
|
||||||
| **Vulnerability Scan** | A specific 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)). |
|
| **Vulnerability Scan** | 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. |
|
||||||
| **Backup Stack Files** | A specific stack on a specific node | Copies the stack's compose file and `.env` to `<DATA_DIR>/backups/<stack>/`. One slot per stack; each run overwrites the previous backup. For a versioned archive use a Fleet Snapshot instead. |
|
| **Backup Stack Files** | A specific stack on a specific node | Copies the stack's compose file and `.env` to `<DATA_DIR>/backups/<stack>/`. One slot per stack; each run overwrites the previous backup. For a versioned archive use a Fleet Snapshot instead. |
|
||||||
| **Stop Stack** | A specific stack on a specific node | Runs `docker compose stop`. Containers are stopped but preserved. Use for off-hours power saving when you want a fast restart later. |
|
| **Stop Stack** | A specific stack on a specific node | Runs `docker compose stop`. Containers are stopped but preserved. Use for off-hours power saving when you want a fast restart later. |
|
||||||
| **Take Stack Down** | A specific stack on a specific node | Runs `docker compose down`. Containers are removed. Use to fully release resources when the stack is not needed for an extended period. |
|
| **Take Stack Down** | A specific stack on a specific node | Runs `docker compose down`. Containers are removed. Use to fully release resources when the stack is not needed for an extended period. |
|
||||||
@@ -87,10 +87,11 @@ Common fields:
|
|||||||
|
|
||||||
Conditional fields per action:
|
Conditional fields per action:
|
||||||
|
|
||||||
- **Stack actions** (Restart Stack, Auto-update Stack, Backup Stack Files, Stop / Take Down / Start Stack) add a **Node** combobox and a **Stack** combobox. Restart Stack additionally renders a **Services** checkbox grid sourced from the stack's compose services, so you can scope the restart to a subset instead of restarting the entire stack.
|
- **Stack actions** (Restart Stack, Auto-update Stack, Backup Stack Files, Stop / Take Down / Start Stack) add a **Node** combobox and a **Stack** combobox. Restart Stack additionally renders a **Services** checkbox grid sourced from the stack's compose services on the selected node, so you can scope the restart to a subset instead of restarting the entire stack.
|
||||||
- **Auto-update All Stacks** adds a **Node** combobox with the helper text "Every stack on the selected node will be checked and updated when new images are available."
|
- **Auto-update All Stacks** adds a **Node** combobox with the helper text "Every stack on the selected node will be checked and updated when new images are available."
|
||||||
- **Vulnerability Scan** adds a **Node** combobox with the helper text "Every image on the selected node will be scanned."
|
- **Vulnerability Scan** adds a **Node** combobox listing local nodes only, with the helper text "Every image on the selected node will be scanned. Scans run on local nodes only."
|
||||||
- **System Prune** adds 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.
|
- **System Prune** 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.
|
||||||
|
- **Fleet Snapshot** shows a read-only **Scope: Entire fleet** summary instead of a Node or Stack picker, because it captures every node.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/scheduled-operations/create-restart.png" alt="The New scheduled task modal configured for a Restart Stack task. Name reads 'Nightly staging restart'. Action is Restart Stack. Node is Local. Stack is 'audit-mesh-prod'. A Services group with helper text '(leave empty for all)' shows two unchecked checkboxes labelled 'echo' and 'prober'. Cron Expression is '0 3 * * *' with the preview 'At 03:00 AM'. The Enabled toggle reads ON. The Delete after successful run checkbox is unchecked. Cancel and Create buttons sit at the bottom right." />
|
<img src="/images/scheduled-operations/create-restart.png" alt="The New scheduled task modal configured for a Restart Stack task. Name reads 'Nightly staging restart'. Action is Restart Stack. Node is Local. Stack is 'audit-mesh-prod'. A Services group with helper text '(leave empty for all)' shows two unchecked checkboxes labelled 'echo' and 'prober'. Cron Expression is '0 3 * * *' with the preview 'At 03:00 AM'. The Enabled toggle reads ON. The Delete after successful run checkbox is unchecked. Cancel and Create buttons sit at the bottom right." />
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
const res = await apiFetch('/nodes', { localOnly: true });
|
const res = await apiFetch('/nodes', { localOnly: true });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setNodes(data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })));
|
setNodes(data.map((n: { id: number; name: string; type: 'local' | 'remote' }) => ({ id: n.id, name: n.name, type: n.type })));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical
|
// Non-critical
|
||||||
@@ -160,7 +160,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const fetchServices = async () => {
|
const fetchServices = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`/stacks/${encodeURIComponent(formTargetId)}/services`);
|
// Load services from the selected node so remote-node restart schedules
|
||||||
|
// discover the right services instead of the hub's.
|
||||||
|
const endpoint = `/stacks/${encodeURIComponent(formTargetId)}/services`;
|
||||||
|
const res = formNodeId
|
||||||
|
? await fetchForNode(endpoint, parseInt(formNodeId, 10))
|
||||||
|
: await apiFetch(endpoint);
|
||||||
if (res.ok && !cancelled) {
|
if (res.ok && !cancelled) {
|
||||||
setAvailableServices(await res.json());
|
setAvailableServices(await res.json());
|
||||||
}
|
}
|
||||||
@@ -170,7 +175,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
};
|
};
|
||||||
fetchServices();
|
fetchServices();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [formAction, formTargetId]);
|
}, [formAction, formTargetId, formNodeId]);
|
||||||
|
|
||||||
// Re-fetch stacks when node changes
|
// Re-fetch stacks when node changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -221,7 +226,10 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const actionDef = getActionById(formAction);
|
const actionDef = getActionById(formAction);
|
||||||
if (!actionDef) return;
|
if (!actionDef) {
|
||||||
|
toast.error('This scheduled action is no longer supported.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
name: formName,
|
name: formName,
|
||||||
@@ -230,24 +238,13 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
cron_expression: formCron,
|
cron_expression: formCron,
|
||||||
enabled: formEnabled,
|
enabled: formEnabled,
|
||||||
delete_after_run: formDeleteAfterRun,
|
delete_after_run: formDeleteAfterRun,
|
||||||
|
target_id: actionDef.requiresStack ? formTargetId : null,
|
||||||
|
node_id: 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (actionDef.requiresStack) {
|
|
||||||
body.target_id = formTargetId;
|
|
||||||
}
|
|
||||||
if (actionDef.requiresNode) {
|
|
||||||
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
|
|
||||||
}
|
|
||||||
if (formAction === 'prune' && formPruneTargets.length > 0) {
|
|
||||||
body.prune_targets = formPruneTargets;
|
|
||||||
}
|
|
||||||
if (formAction === 'restart' && formTargetServices.length > 0) {
|
|
||||||
body.target_services = formTargetServices;
|
|
||||||
}
|
|
||||||
if (formAction === 'prune' && formPruneLabelFilter.trim()) {
|
|
||||||
body.prune_label_filter = formPruneLabelFilter.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = editingTask
|
const res = editingTask
|
||||||
@@ -342,8 +339,14 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
const currentAction = getActionById(formAction);
|
const currentAction = getActionById(formAction);
|
||||||
const cronDescription = getCronDescription(formCron);
|
const cronDescription = getCronDescription(formCron);
|
||||||
const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]);
|
const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]);
|
||||||
|
// Scan and prune run on the hub-local Docker daemon only; remote nodes are excluded from their pickers.
|
||||||
|
const localNodeOptions = useMemo(
|
||||||
|
() => nodes.filter(n => n.type === 'local').map(n => ({ value: String(n.id), label: n.name })),
|
||||||
|
[nodes],
|
||||||
|
);
|
||||||
|
const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions;
|
||||||
const isSaveDisabled =
|
const isSaveDisabled =
|
||||||
saving || !formName || !formCron
|
saving || !currentAction || !formName || !formCron
|
||||||
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|
||||||
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|
||||||
|| (formAction === 'prune' && formPruneTargets.length === 0);
|
|| (formAction === 'prune' && formPruneTargets.length === 0);
|
||||||
@@ -715,11 +718,21 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{formAction === 'snapshot' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Scope</Label>
|
||||||
|
<div className="flex h-9 w-full items-center rounded-md border border-glass-border bg-input px-3 text-sm text-muted-foreground">
|
||||||
|
Entire fleet
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Captures every node's compose and .env files. No node or stack to choose.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentAction?.requiresNode && !currentAction.requiresStack && (
|
{currentAction?.requiresNode && !currentAction.requiresStack && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Node</Label>
|
<Label>Node</Label>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={nodeOptions}
|
options={currentNodeOptions}
|
||||||
value={formNodeId}
|
value={formNodeId}
|
||||||
onValueChange={setFormNodeId}
|
onValueChange={setFormNodeId}
|
||||||
placeholder="Select node..."
|
placeholder="Select node..."
|
||||||
|
|||||||
@@ -51,16 +51,17 @@ function makeTask(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let tasksFixture: ScheduledTask[];
|
let tasksFixture: ScheduledTask[];
|
||||||
let nodesFixture: { id: number; name: string }[];
|
let nodesFixture: { id: number; name: string; type: 'local' | 'remote' }[];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tasksFixture = [];
|
tasksFixture = [];
|
||||||
nodesFixture = [{ id: 1, name: 'hub' }, { id: 2, name: 'edge' }];
|
nodesFixture = [{ id: 1, name: 'hub', type: 'local' }, { id: 2, name: 'edge', type: 'remote' }];
|
||||||
mockedFetch.mockReset();
|
mockedFetch.mockReset();
|
||||||
mockedFetchForNode.mockReset();
|
mockedFetchForNode.mockReset();
|
||||||
|
|
||||||
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
||||||
if (url === '/scheduled-tasks' && opts?.method === 'POST') return jsonResponse({ id: 99 }, { status: 201 });
|
if (url === '/scheduled-tasks' && opts?.method === 'POST') return jsonResponse({ id: 99 }, { status: 201 });
|
||||||
|
if (/^\/scheduled-tasks\/\d+$/.test(url) && opts?.method === 'PUT') return jsonResponse({ id: Number(url.split('/').pop()) });
|
||||||
if (url === '/scheduled-tasks') return jsonResponse(tasksFixture);
|
if (url === '/scheduled-tasks') return jsonResponse(tasksFixture);
|
||||||
if (url === '/nodes') return jsonResponse(nodesFixture);
|
if (url === '/nodes') return jsonResponse(nodesFixture);
|
||||||
if (url === '/stacks') return jsonResponse([]);
|
if (url === '/stacks') return jsonResponse([]);
|
||||||
@@ -152,6 +153,10 @@ describe('ScheduledOperationsView', () => {
|
|||||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||||
|
|
||||||
|
// Prune is now node-scoped: pick the local node from its Node combobox.
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -164,6 +169,7 @@ describe('ScheduledOperationsView', () => {
|
|||||||
name: 'cleanup',
|
name: 'cleanup',
|
||||||
target_type: 'system',
|
target_type: 'system',
|
||||||
action: 'prune',
|
action: 'prune',
|
||||||
|
node_id: 1,
|
||||||
cron_expression: '0 3 * * *',
|
cron_expression: '0 3 * * *',
|
||||||
prune_targets: ['containers', 'images', 'networks', 'volumes'],
|
prune_targets: ['containers', 'images', 'networks', 'volumes'],
|
||||||
});
|
});
|
||||||
@@ -171,6 +177,106 @@ describe('ScheduledOperationsView', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps Create disabled for a prune until a node is selected', async () => {
|
||||||
|
render(<ScheduledOperationsView />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||||
|
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'cleanup');
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||||
|
|
||||||
|
// Prune targets default to all four, but with no node the gate must hold.
|
||||||
|
expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||||
|
expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes remote nodes from the System Prune node picker', async () => {
|
||||||
|
render(<ScheduledOperationsView />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||||
|
|
||||||
|
// Open the Node combobox; only the local node should be listed.
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||||
|
expect(await screen.findByRole('button', { name: 'hub' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'edge' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes remote nodes from the Vulnerability Scan node picker', async () => {
|
||||||
|
render(<ScheduledOperationsView />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Vulnerability Scan' }));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||||
|
expect(await screen.findByRole('button', { name: 'hub' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'edge' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits a vulnerability scan create with the selected local node', async () => {
|
||||||
|
render(<ScheduledOperationsView />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||||
|
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'scan-local');
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Vulnerability Scan' }));
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const postCall = mockedFetch.mock.calls.find(
|
||||||
|
([url, opts]) => url === '/scheduled-tasks' && opts?.method === 'POST',
|
||||||
|
);
|
||||||
|
expect(postCall).toBeTruthy();
|
||||||
|
const body = JSON.parse(postCall![1].body);
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
name: 'scan-local',
|
||||||
|
target_type: 'system',
|
||||||
|
action: 'scan',
|
||||||
|
node_id: 1,
|
||||||
|
target_id: null,
|
||||||
|
prune_targets: null,
|
||||||
|
target_services: null,
|
||||||
|
prune_label_filter: null,
|
||||||
|
});
|
||||||
|
expect(postCall![1].localOnly).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a read-only "Entire fleet" scope for Fleet Snapshot', async () => {
|
||||||
|
render(<ScheduledOperationsView />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Fleet Snapshot' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Entire fleet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads Restart Stack services from the selected node', async () => {
|
||||||
|
render(<ScheduledOperationsView />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||||
|
// Default action is Restart Stack. Pick the remote node, then a stack on it.
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'edge' }));
|
||||||
|
// The Stack combobox unlocks once a node is chosen; pick a stack to drive discovery.
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[2]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'web' }));
|
||||||
|
|
||||||
|
// Service discovery must target the selected node, not the hub-local default.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFetchForNode).toHaveBeenCalledWith('/stacks/web/services', 2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the five registry category lanes in the timeline view', async () => {
|
it('renders the five registry category lanes in the timeline view', async () => {
|
||||||
render(<ScheduledOperationsView />);
|
render(<ScheduledOperationsView />);
|
||||||
// Timeline is the default view; the lane track always renders.
|
// Timeline is the default view; the lane track always renders.
|
||||||
@@ -217,10 +323,10 @@ describe('ScheduledOperationsView', () => {
|
|||||||
expect(screen.queryByText('Node')).not.toBeInTheDocument();
|
expect(screen.queryByText('Node')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
|
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
|
||||||
|
|
||||||
// Prune: Prune Targets shown, no Node.
|
// Prune: local-only Node plus Prune Targets.
|
||||||
await selectAction('System Prune');
|
await selectAction('System Prune');
|
||||||
expect(screen.getByText('Prune Targets')).toBeInTheDocument();
|
expect(screen.getByText('Prune Targets')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Node')).not.toBeInTheDocument();
|
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits node_id and target_id for a stack update save', async () => {
|
it('emits node_id and target_id for a stack update save', async () => {
|
||||||
@@ -286,4 +392,40 @@ describe('ScheduledOperationsView', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears stack-only fields when editing a restart task into a fleet snapshot', async () => {
|
||||||
|
tasksFixture = [makeTask({
|
||||||
|
id: 42,
|
||||||
|
name: 'restart-web',
|
||||||
|
target_type: 'stack',
|
||||||
|
target_id: 'web',
|
||||||
|
node_id: 1,
|
||||||
|
action: 'restart',
|
||||||
|
target_services: JSON.stringify(['web']),
|
||||||
|
})];
|
||||||
|
render(<ScheduledOperationsView />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||||
|
await userEvent.click(await screen.findByTitle('Edit'));
|
||||||
|
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Fleet Snapshot' }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const putCall = mockedFetch.mock.calls.find(
|
||||||
|
([url, opts]) => url === '/scheduled-tasks/42' && opts?.method === 'PUT',
|
||||||
|
);
|
||||||
|
expect(putCall).toBeTruthy();
|
||||||
|
const body = JSON.parse(putCall![1].body);
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
target_type: 'fleet',
|
||||||
|
action: 'snapshot',
|
||||||
|
target_id: null,
|
||||||
|
node_id: null,
|
||||||
|
target_services: null,
|
||||||
|
prune_targets: null,
|
||||||
|
prune_label_filter: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ describe('scheduledActions registry', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks scan and prune as local-node actions', () => {
|
||||||
|
expect(getActionById('scan')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
|
||||||
|
expect(getActionById('prune')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
|
||||||
|
});
|
||||||
|
|
||||||
it('getActionById resolves a known id and returns undefined otherwise', () => {
|
it('getActionById resolves a known id and returns undefined otherwise', () => {
|
||||||
expect(getActionById('restart')?.label).toBe('Restart Stack');
|
expect(getActionById('restart')?.label).toBe('Restart Stack');
|
||||||
expect(getActionById('nope')).toBeUndefined();
|
expect(getActionById('nope')).toBeUndefined();
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface ScheduledActionDefinition {
|
|||||||
requiresNode: boolean;
|
requiresNode: boolean;
|
||||||
requiresStack: boolean;
|
requiresStack: boolean;
|
||||||
supportsServiceSelection: boolean;
|
supportsServiceSelection: boolean;
|
||||||
|
nodeScope?: 'local';
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +47,8 @@ export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [
|
|||||||
{ id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
{ id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
||||||
{ id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks', shortLabel: 'update', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Every stack on the selected node will be checked and updated when new images are available.' },
|
{ id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks', shortLabel: 'update', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Every stack on the selected node will be checked and updated when new images are available.' },
|
||||||
{ id: 'snapshot', backendAction: 'snapshot', label: 'Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false },
|
{ id: 'snapshot', backendAction: 'snapshot', label: 'Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false },
|
||||||
{ id: 'prune', backendAction: 'prune', label: 'System Prune', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false },
|
{ id: 'prune', backendAction: 'prune', label: 'System Prune', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Resources are pruned on the selected node. Prunes run on local nodes only.' },
|
||||||
{ id: 'scan', backendAction: 'scan', label: 'Vulnerability Scan', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Every image on the selected node will be scanned.' },
|
{ id: 'scan', backendAction: 'scan', label: 'Vulnerability Scan', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Every image on the selected node will be scanned. Scans run on local nodes only.' },
|
||||||
{ id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
{ id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
||||||
{ id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack (keep containers)', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
{ id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack (keep containers)', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
||||||
{ id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down (remove containers)', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
{ id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down (remove containers)', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
||||||
|
|||||||
@@ -35,4 +35,5 @@ export interface TaskRun {
|
|||||||
export interface NodeOption {
|
export interface NodeOption {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'local' | 'remote';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user