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:
Anso
2026-07-28 13:00:47 -04:00
committed by GitHub
parent fa503ddf27
commit 72cdbb0eaa
18 changed files with 883 additions and 72 deletions
@@ -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');
+21 -6
View File
@@ -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);