Skip to content

Commit eb49899

Browse files
committed
luci-mod-system: give crontab some helpers
Signed-off-by: Paul Donald <newtwen+github@gmail.com>
1 parent 7cb2f65 commit eb49899

File tree

1 file changed

+259
-10
lines changed
  • modules/luci-mod-system/htdocs/luci-static/resources/view/system

1 file changed

+259
-10
lines changed

modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontab.js

Lines changed: 259 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,281 @@
55

66
var isReadonlyView = !L.hasViewPermission() || null;
77

8+
9+
const yearly = { minute: '@yearly', command: '', comment: '', };
10+
const monthly = { minute: '@monthly', command: '', comment: '', };
11+
const weekly = { minute: '@weekly', command: '', comment: '', };
12+
const daily = { minute: '@daily', command: '', comment: '', };
13+
const hourly = { minute: '@hourly', command: '', comment: '', };
14+
const a_task = { minute: '*', hour: '*', day: '*', month: '*', weekday: '*', command: '', comment: '', };
15+
const alias = { minute: '@', hour: '*', day: '*', month: '*', weekday: '*', command: '', comment: '', };
16+
17+
const width = 'width:160px';
18+
19+
820
return view.extend({
9-
load: function() {
21+
load() {
1022
return L.resolveDefault(fs.read('/etc/crontabs/root'), '');
1123
},
1224

13-
handleSave: function(ev) {
14-
var value = (document.querySelector('textarea').value || '').trim().replace(/\r\n/g, '\n') + '\n';
25+
handleSave(ev) {
26+
const tasks = Array.from(document.querySelectorAll('.crontab-row')).map(row => {
27+
const getFieldValue = (fieldName) => {
28+
const mode = row.querySelector(`.${fieldName}-mode`)?.value;
1529

16-
return fs.write('/etc/crontabs/root', value).then(function(rc) {
17-
document.querySelector('textarea').value = value;
18-
ui.addNotification(null, E('p', _('Contents have been saved.')), 'info');
30+
switch (mode) {
31+
case 'custom':
32+
const custom = row.querySelector(`.${fieldName}-custom`).value.trim();
33+
return custom;
34+
case 'ignore':
35+
return '*';
36+
case 'interval':
37+
const interval = row.querySelector(`.${fieldName}-interval`).value.trim();
38+
return interval ? `*/${interval}` : '*'; // Every Xth unit
39+
case 'specific':
40+
const specific = row.querySelector(`.${fieldName}-specific`).value.trim();
41+
return specific || '*'; // Specific units
42+
}
43+
};
1944

45+
const comment = row.querySelector('.comment').value.trim();
46+
return {
47+
minute: getFieldValue('minute') || '*',
48+
hour: getFieldValue('hour') || '*',
49+
day: getFieldValue('day') || '*',
50+
month: getFieldValue('month') || '*',
51+
weekday: getFieldValue('weekday') || '*',
52+
command: row.querySelector('.command').value.trim(),
53+
comment: comment ? `# ${comment}` : '',
54+
};
55+
});
56+
57+
const value = tasks.map(task => {
58+
if (task.minute[0] !== '@')
59+
return `${task.minute} ${task.hour} ${task.day} ${task.month} ${task.weekday} ${task.command} ${task.comment}`;
60+
else
61+
return `${task.minute} ${task.command} ${task.comment}`;
62+
}).join('\n') + '\n';
63+
64+
return fs.write('/etc/crontabs/root', value).then(() => {
65+
ui.addNotification(null, E('p', _('Contents have been saved.')), 5000, 'info');
2066
return fs.exec('/etc/init.d/cron', [ 'reload' ]);
21-
}).catch(function(e) {
67+
}).catch(e => {
2268
ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e.message)));
2369
});
2470
},
2571

26-
render: function(crontab) {
72+
render(crontab) {
73+
const tasks = (crontab || '').split('\n').filter(line => line.trim() && !line.startsWith('#')).map(line => {
74+
const parts = line.split(/\s+/);
75+
const commentIndex = parts.findIndex(part => part.startsWith('#'));
76+
// exclude the '#' character:
77+
if (commentIndex !== -1) parts[commentIndex] = parts[commentIndex].substring(1).trim();
78+
const comment = commentIndex !== -1 ? parts.slice(commentIndex).join(' ') : '';
79+
80+
if(parts[0][0] == '@') {
81+
const updatedTask = { ...alias };
82+
updatedTask.minute = parts[0];
83+
updatedTask.command = commentIndex !== -1 ? parts.slice(1, commentIndex).join(' ') : parts.slice(1).join(' ');
84+
updatedTask.comment = comment;
85+
return updatedTask;
86+
}
87+
88+
return {
89+
minute: parts[0] || '',
90+
hour: parts[1] || '',
91+
day: parts[2] || '',
92+
month: parts[3] || '',
93+
weekday: parts[4] || '',
94+
command: commentIndex !== -1 ? parts.slice(5, commentIndex).join(' ') : parts.slice(5).join(' ') || '',
95+
comment: comment || '',
96+
};
97+
});
98+
2799
return E([
28100
E('h2', _('Scheduled Tasks')),
29-
E('p', { 'class': 'cbi-section-descr' }, _('This is the system crontab in which scheduled tasks can be defined.')),
30-
E('p', {}, E('textarea', { 'style': 'width:100%', 'rows': 25, 'disabled': isReadonlyView }, [ crontab != null ? crontab : '' ]))
101+
E('p', { 'class': 'cbi-section-descr' }, _('Define your scheduled tasks for root below.')),
102+
E('a', { 'href': 'https://openwrt.org/docs/guide-user/base-system/cron', 'target':'_blank' }, _('Crontab help wiki')),
103+
E('table', { 'class': 'table', }, [
104+
E('thead', {}, [
105+
E('tr', {}, [
106+
E('th', {}, _('Minute')),
107+
E('th', {}, _('Hour')),
108+
E('th', {}, _('Day')),
109+
E('th', {}, _('Month')),
110+
E('th', {}, _('Weekday')),
111+
E('th', {}, _('Command')),
112+
E('th', {}, _('Comment')),
113+
E('th', {}, _('Actions'))
114+
])
115+
]),
116+
E('tbody', { 'id': 'crontab-rows' }, this.renderTaskRows(tasks)),
117+
E('hr', {}),
118+
E('tfoot', {}, [
119+
E('tr', {}, [
120+
E('td', { 'colspan': 1 }, [
121+
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', alias ) }, _('Add alias'))
122+
]),
123+
E('td', { 'colspan': 1 }, [
124+
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask') }, _('Add task'))
125+
]),
126+
E('td', { 'colspan': 1 }, [
127+
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', yearly ) }, _('Yearly task'))
128+
]),
129+
E('td', { 'colspan': 1 }, [
130+
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', monthly ) }, _('Monthly task'))
131+
]),
132+
E('td', { 'colspan': 1 }, [
133+
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', weekly ) }, _('Weekly task'))
134+
]),
135+
E('td', { 'colspan': 1 }, [
136+
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', daily ) }, _('Daily task'))
137+
]),
138+
E('td', { 'colspan': 1 }, [
139+
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', hourly ) }, _('Hourly task'))
140+
]),
141+
])
142+
])
143+
])
31144
]);
32145
},
33146

147+
renderTaskRows(tasks) {
148+
const rows = [];
149+
150+
tasks.forEach((task, index) => {
151+
rows.push(E('tr', { 'class': 'crontab-hr' }, E('td', { 'colspan': 7 }, E('hr', { 'style': 'margin: 10px 0;' }))));
152+
if (task.minute[0] == '@')
153+
rows.push(this.renderAliasRow(task));
154+
else
155+
rows.push(this.renderTaskRow(task));
156+
});
157+
158+
return rows;
159+
},
160+
161+
renderAliasRow(task) {
162+
return E('tr', { 'class': 'crontab-row' }, [
163+
this.createTimeDropdown('minute', task.minute, 'Minute'),
164+
E('td', {},
165+
E('div', {}, [
166+
E('label', {}, _('Command')),
167+
E('input', { 'type': 'text', 'class': 'command', 'style': width, 'value': task.command, 'disabled': isReadonlyView }),
168+
]),
169+
),
170+
E('td', {},
171+
E('div', {}, [
172+
E('label', {}, _('Comment')),
173+
E('input', { 'type': 'text', 'class': 'comment', 'style': width, 'value': task.comment, 'disabled': isReadonlyView }),
174+
]),
175+
),
176+
E('td', {}, [
177+
E('button', { 'class': 'btn remove-task cbi-button-negative', 'click': ui.createHandlerFn(this, 'removeTask') }, _('Remove'))
178+
])
179+
]);
180+
},
181+
182+
renderTaskRow(task) {
183+
return E('tr', { 'class': 'crontab-row' }, [
184+
this.createTimeDropdown('minute', task.minute, 'Minute', 0, 59),
185+
this.createTimeDropdown('hour', task.hour, 'Hour', 0, 23),
186+
this.createTimeDropdown('day', task.day, 'Day', 0, 31),
187+
this.createTimeDropdown('month', task.month, 'Month', 0, 12),
188+
this.createTimeDropdown('weekday', task.weekday, 'Weekday', 0, 6),
189+
E('td', {}, E('input', { 'type': 'text', 'class': 'command', 'style': width, 'value': task.command, 'disabled': isReadonlyView })),
190+
E('td', {}, E('input', { 'type': 'text', 'class': 'comment', 'style': width, 'value': task.comment, 'disabled': isReadonlyView })),
191+
E('td', {}, [
192+
E('button', { 'class': 'btn remove-task cbi-button-negative', 'click': ui.createHandlerFn(this, 'removeTask') }, _('Remove'))
193+
])
194+
]);
195+
},
196+
197+
createTimeDropdown(fieldName, value, label, min, max) {
198+
const mode = value.includes(',') || parseInt(value, 10) >= 0 || value.startsWith('@')
199+
? (value.split(',').filter(v => v.startsWith('*/')).length > 1 || value.startsWith('@') ? 'custom' : 'specific')
200+
: value.startsWith('*/')
201+
? 'interval'
202+
: 'ignore';
203+
204+
const intervalValue = mode === 'interval' ? value.substring(2) : '';
205+
const specificValue = mode === 'specific' ? value : '';
206+
const customValue = mode === 'custom' ? value : '';
207+
208+
return E('td', {}, [
209+
E('div', { 'class': 'dropdown-container' }, [
210+
E('select', {
211+
'class': `${fieldName}-mode`,
212+
'style': width,
213+
'change': ev => this.updateDropdownMode(ev, fieldName)
214+
}, [
215+
E('option', { 'value': 'ignore', 'style': width,
216+
...(mode === 'ignore' ? { 'selected': 'true' } : {})
217+
}, _('-')),
218+
E('option', { 'value': 'interval', 'style': width,
219+
...(mode === 'interval' ? { 'selected': 'true' } : {})
220+
}, _('Every Xth')),
221+
E('option', { 'value': 'specific', 'style': width,
222+
...(mode === 'specific' ? { 'selected': 'true' } : {})
223+
}, _('Specific')),
224+
E('option', { 'value': 'custom', 'style': width,
225+
...(mode === 'custom' ? { 'selected': 'true' } : {})
226+
}, _('Custom'))
227+
]),
228+
E('div', { 'class': `${fieldName}-input ignore-input`, 'style': mode === 'ignore' ? '' : 'display:none;' }, [
229+
E('input', { 'type': 'text', 'class': fieldName, 'value': '*', 'style': mode === 'ignore' ? 'display:none;': width,
230+
})
231+
]),
232+
E('div', { 'class': `${fieldName}-input interval-input`, 'style': mode === 'interval' ? width : 'display:none;' }, [
233+
E('label', {}, _('Every')),
234+
E('input', { 'type': 'number', 'min': min, 'max': max, 'class': `${fieldName}-interval`, 'value': intervalValue, 'style': width }),
235+
E('span', {}, _(label.toLowerCase() + 's'))
236+
]),
237+
E('div', { 'class': `${fieldName}-input specific-input`, 'style': mode === 'specific' ? width : 'display:none;' }, [
238+
E('label', {}, _('At')),
239+
E('input', { 'type': 'text', 'class': `${fieldName}-specific`, 'value': specificValue, 'style': width }),
240+
E('span', {}, _(label.toLowerCase() + 's (comma-separated)'))
241+
]),
242+
E('div', { 'class': `${fieldName}-input custom-input`, 'style': mode === 'custom' ? width : 'display:none;' }, [
243+
E('label', {}, _('Value')),
244+
E('input', { 'type': 'text', 'class': `${fieldName}-custom`, 'value': customValue, 'style': width })
245+
]),
246+
])
247+
]);
248+
},
249+
250+
updateDropdownMode(ev, fieldName) {
251+
const dropdown = ev.target.closest('.dropdown-container');
252+
const mode = ev.target.value;
253+
254+
dropdown.querySelectorAll(`.${fieldName}-input`).forEach(input => {
255+
input.style.display = 'none';
256+
});
257+
258+
dropdown.querySelector(`.${fieldName}-input.${mode}-input`).style.display = '';
259+
},
260+
261+
addTask(param) {
262+
const tbody = document.getElementById('crontab-rows');
263+
264+
let newTask
265+
if (param?.type !== 'click') {
266+
newTask = param;
267+
} else {
268+
newTask = a_task;
269+
}
270+
const newRows = this.renderTaskRows([newTask]);
271+
newRows.forEach(row => {
272+
tbody.appendChild(row);
273+
})
274+
},
275+
276+
removeTask(ev) {
277+
const row = ev.target.closest('.crontab-row');
278+
const hr = row.previousElementSibling;
279+
if (hr) hr.remove();
280+
if (row) row.remove();
281+
},
282+
34283
handleSaveApply: null,
35284
handleReset: null
36285
});

0 commit comments

Comments
 (0)