mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
fix(scheduler): reject 6-field cron in Scheduled Operations (#1435)
* fix(scheduler): reject 6-field cron in Scheduled Operations Create and edit validation parsed cron with cron-parser, which accepts both 5- and 6-field expressions, while the form, presets, and docs all describe a 5-field cron. Because the scheduler ticks once per minute, a leading seconds field can never improve precision, so a 6-field expression was silently accepted but never honored on its stated schedule. Add a field-count guard on both sides: the API rejects 6-field input at create and edit with a clear message, and the form surfaces the same error inline and disables save. Cron nicknames such as @daily still pass. Document the five-field requirement in the cron reference. * chore: merge main into scheduled cron validation * fix: avoid logging policy bypass actor in debug output
This commit is contained in:
@@ -14,7 +14,7 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
|
||||
import { getCronDescription, formatTimestamp } from '@/lib/scheduling';
|
||||
import { getCronDescription, getCronFieldError, formatTimestamp } from '@/lib/scheduling';
|
||||
import {
|
||||
SCHEDULED_ACTIONS,
|
||||
SCHEDULED_ACTION_CATEGORIES,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
resolveTaskAction,
|
||||
} from '@/lib/scheduledActions';
|
||||
|
||||
const DEFAULT_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'];
|
||||
const TIMELINE_WINDOW_HOURS = 24;
|
||||
const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000;
|
||||
|
||||
@@ -72,7 +73,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const [formCron, setFormCron] = useState('0 3 * * *');
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false);
|
||||
const [formPruneTargets, setFormPruneTargets] = useState<string[]>(['containers', 'images', 'networks', 'volumes']);
|
||||
const [formPruneTargets, setFormPruneTargets] = useState<string[]>(DEFAULT_PRUNE_TARGETS);
|
||||
const [formTargetServices, setFormTargetServices] = useState<string[]>([]);
|
||||
const [formPruneLabelFilter, setFormPruneLabelFilter] = useState('');
|
||||
const [availableServices, setAvailableServices] = useState<string[]>([]);
|
||||
@@ -153,7 +154,8 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (formAction !== 'restart' || !formTargetId) {
|
||||
const actionDef = getActionById(formAction);
|
||||
if (!actionDef?.supportsServiceSelection || !formTargetId) {
|
||||
setAvailableServices([]);
|
||||
return;
|
||||
}
|
||||
@@ -198,7 +200,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
setFormCron('0 3 * * *');
|
||||
setFormEnabled(true);
|
||||
setFormDeleteAfterRun(false);
|
||||
setFormPruneTargets(['containers', 'images', 'networks', 'volumes']);
|
||||
setFormPruneTargets(DEFAULT_PRUNE_TARGETS);
|
||||
setFormTargetServices([]);
|
||||
setFormPruneLabelFilter('');
|
||||
setDialogOpen(true);
|
||||
@@ -215,7 +217,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
setFormEnabled(task.enabled === 1);
|
||||
setFormDeleteAfterRun((task.delete_after_run ?? 0) === 1);
|
||||
setFormPruneTargets(
|
||||
task.prune_targets ? JSON.parse(task.prune_targets) : ['containers', 'images', 'networks', 'volumes']
|
||||
task.prune_targets ? JSON.parse(task.prune_targets) : DEFAULT_PRUNE_TARGETS
|
||||
);
|
||||
setFormTargetServices(
|
||||
task.target_services ? JSON.parse(task.target_services) : []
|
||||
@@ -338,6 +340,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
|
||||
const currentAction = getActionById(formAction);
|
||||
const cronDescription = getCronDescription(formCron);
|
||||
const cronFieldError = getCronFieldError(formCron);
|
||||
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(
|
||||
@@ -346,7 +349,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
);
|
||||
const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions;
|
||||
const isSaveDisabled =
|
||||
saving || !currentAction || !formName || !formCron
|
||||
saving || !currentAction || !formName || !formCron || !!cronFieldError
|
||||
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|
||||
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|
||||
|| (formAction === 'prune' && formPruneTargets.length === 0);
|
||||
@@ -748,7 +751,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<div className="space-y-2">
|
||||
<Label>Prune Targets</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['containers', 'images', 'networks', 'volumes'].map(target => (
|
||||
{DEFAULT_PRUNE_TARGETS.map(target => (
|
||||
<label key={target} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={formPruneTargets.includes(target)}
|
||||
@@ -784,7 +787,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
onChange={e => setFormCron(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{cronDescription}</p>
|
||||
{cronFieldError
|
||||
? <p className="text-xs text-destructive">{cronFieldError}</p>
|
||||
: <p className="text-xs text-muted-foreground">{cronDescription}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -177,6 +177,32 @@ describe('ScheduledOperationsView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Create when the cron expression has a seconds field', 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' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||
|
||||
const createButton = screen.getByRole('button', { name: 'Create' });
|
||||
expect(createButton).toBeEnabled();
|
||||
|
||||
const cronInput = screen.getByPlaceholderText('0 3 * * *');
|
||||
await userEvent.clear(cronInput);
|
||||
await userEvent.type(cronInput, '30 0 3 * * *');
|
||||
|
||||
expect(screen.getByText(/seconds field is not supported/i)).toBeInTheDocument();
|
||||
expect(createButton).toBeDisabled();
|
||||
|
||||
await userEvent.clear(cronInput);
|
||||
await userEvent.type(cronInput, '0 4 * * *');
|
||||
expect(createButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('keeps Create disabled for a prune until a node is selected', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user