123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- From 388238b9baf8375e5474167c987a4a8a3358b559 Mon Sep 17 00:00:00 2001
- From: Paul Donald <[email protected]>
- Date: Wed, 23 Apr 2025 00:03:25 +0200
- Subject: [PATCH] luci-mod-system: give crontab a helper page
- Reference: https://github.com/openwrt/luci/pull/7495
- Signed-off-by: Paul Donald <[email protected]>
- ---
- .../resources/view/system/crontabhelper.js | 334 ++++++++++++++++++
- .../share/luci/menu.d/luci-mod-system.json | 14 +-
- 2 files changed, 347 insertions(+), 1 deletion(-)
- create mode 100644 feeds/luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontabhelper.js
- diff --git a/feeds/luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontabhelper.js b/feeds/luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontabhelper.js
- new file mode 100644
- index 000000000000..861d4d1f77a5
- --- /dev/null
- +++ b/feeds/luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontabhelper.js
- @@ -0,0 +1,334 @@
- +'use strict';
- +'require view';
- +'require fs';
- +'require ui';
- +
- +var isReadonlyView = !L.hasViewPermission() || null;
- +
- +
- +const yearly = { minute: '@yearly', command: '', comment: '', };
- +const monthly = { minute: '@monthly', command: '', comment: '', };
- +const weekly = { minute: '@weekly', command: '', comment: '', };
- +const daily = { minute: '@daily', command: '', comment: '', };
- +const hourly = { minute: '@hourly', command: '', comment: '', };
- +const a_task = { minute: '*', hour: '*', day: '*', month: '*', weekday: '*', command: '', comment: '', };
- +const alias = { minute: '@', hour: '*', day: '*', month: '*', weekday: '*', command: '', comment: '', };
- +
- +const width = 'width:100px;';
- +const double_width = 'width:300px';
- +const padding = 'padding:3px;';
- +const centre = 'text-align:center;';
- +
- +return view.extend({
- + load() {
- + return L.resolveDefault(fs.read('/etc/crontabs/root'), '');
- + },
- +
- + handleSave(ev) {
- + const tasks = Array.from(document.querySelectorAll('.crontab-row')).map(row => {
- + const getFieldValue = (fieldName) => {
- + const mode = row.querySelector(`.${fieldName}-mode`)?.value;
- +
- + switch (mode) {
- + case 'custom':
- + const custom = row.querySelector(`.${fieldName}-custom`)?.value?.trim();
- + return custom;
- + case 'ignore':
- + return '*';
- + case 'interval':
- + const interval = row.querySelector(`.${fieldName}-interval`)?.value?.trim();
- + return interval ? `*/${interval}` : '*'; // Every Xth unit
- + case 'specific':
- + const specific = row.querySelector(`.${fieldName}-specific`)?.value?.trim();
- + return specific || '*'; // Specific units
- + }
- + };
- +
- + const comment = row.querySelector('.comment')?.value?.trim();
- + const minute = row.querySelector('.minute')?.value?.trim();
- +
- + // if it's a # comment row, just stuff the comment and return
- + if (minute == comment)
- + return { iscomment: true, comment: comment };
- + else
- + return {
- + minute: getFieldValue('minute') || '*',
- + hour: getFieldValue('hour') || '*',
- + day: getFieldValue('day') || '*',
- + month: getFieldValue('month') || '*',
- + weekday: getFieldValue('weekday') || '*',
- + command: row.querySelector('.command')?.value?.trim(),
- + comment: comment ? `# ${comment}` : '',
- + };
- + });
- +
- + const value = tasks.map(task => {
- + if (task.iscomment)
- + return `${task.comment}`;
- + else if (task.minute[0] !== '@')
- + return `${task.minute} ${task.hour} ${task.day} ${task.month} ${task.weekday} ${task.command} ${task.comment}`;
- + else
- + return `${task.minute} ${task.command} ${task.comment}`;
- + }).join('\n') + '\n';
- +
- + return fs.write('/etc/crontabs/root', value).then(() => {
- + ui.addTimeLimitedNotification(null, E('p', _('Contents have been saved.')), 5000, 'info');
- + return fs.exec('/etc/init.d/cron', [ 'reload' ]);
- + }).catch(e => {
- + ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e.message)));
- + });
- + },
- +
- + render(crontab) {
- + const tasks = (crontab || '').split('\n').filter(line => line.trim()).map(line => {
- + if (line.startsWith('#'))
- + return {
- + // stash comment lines for saving later
- + minute: line,
- + comment: line,
- + };
- + const parts = line.split(/\s+/);
- + const commentIndex = parts.findIndex(part => part.startsWith('#'));
- + // exclude the '#' character from comments existing in valid command rows:
- + if (commentIndex !== -1) parts[commentIndex] = parts[commentIndex].substring(1)?.trim();
- + const comment = commentIndex !== -1 ? parts.slice(commentIndex).join(' ') : '';
- +
- + if(parts[0][0] == '@') {
- + const updatedTask = { ...alias };
- + updatedTask.minute = parts[0];
- + updatedTask.command = commentIndex !== -1 ? parts.slice(1, commentIndex).join(' ') : parts.slice(1).join(' ');
- + updatedTask.comment = comment;
- + return updatedTask;
- + }
- +
- + return {
- + minute: parts[0] || '',
- + hour: parts[1] || '',
- + day: parts[2] || '',
- + month: parts[3] || '',
- + weekday: parts[4] || '',
- + command: commentIndex !== -1 ? parts.slice(5, commentIndex).join(' ') : parts.slice(5).join(' ') || '',
- + comment: comment || '',
- + };
- + });
- +
- + return E([
- + E('h2', _('Scheduled Tasks')),
- + E('p', { 'class': 'cbi-section-descr' }, _('Define your scheduled tasks for root below.') + '<br/>' +
- + _('CSV - Comma Separated Value(s)')),
- + E('a', { 'href': 'https://openwrt.org/docs/guide-user/base-system/cron', 'target':'_blank' }, _('Crontab help wiki')),
- + E('table', { 'class': 'table', }, [
- + E('thead', {}, [
- + E('tr', {}, [
- + E('th', {}, _('Minute / Alias', 'minute or crontab alias field')),
- + E('th', {}, _('Hour')),
- + E('th', {}, _('Day')),
- + E('th', {}, _('Month')),
- + E('th', {}, _('Weekday')),
- + E('th', {}, _('Command')),
- + E('th', {}, _('Comment')),
- + E('th', {}, _('Action'))
- + ])
- + ]),
- + E('tbody', { 'id': 'crontab-rows' }, this.renderTaskRows(tasks)),
- + E('hr', {}),
- + E('tfoot', {}, [
- + E('tr', {}, [
- + E('td', { 'colspan': 1, 'style': padding }, [
- + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', alias ) }, _('Add alias'))
- + ]),
- + E('td', { 'colspan': 1, 'style': padding }, [
- + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', yearly ) }, _('Yearly task'))
- + ]),
- + E('td', { 'colspan': 1, 'style': padding }, [
- + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', monthly ) }, _('Monthly task'))
- + ]),
- + E('td', { 'colspan': 1, 'style': padding }, [
- + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', weekly ) }, _('Weekly task'))
- + ]),
- + E('td', { 'colspan': 1, 'style': padding }, [
- + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', daily ) }, _('Daily task'))
- + ]),
- + E('td', { 'colspan': 1, 'style': padding }, [
- + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', hourly ) }, _('Hourly task'))
- + ]),
- + E('td', { 'colspan': 1, 'style': padding }, [
- + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask') }, _('Add custom task'))
- + ]),
- + ])
- + ])
- + ])
- + ]);
- + },
- +
- + renderTaskRows(tasks) {
- + const rows = [];
- +
- + tasks.forEach((task, index) => {
- + if (task?.minute.startsWith('#') && task?.comment){
- + rows.push(this.renderCommentRow(task));
- + return;
- + }
- + rows.push(E('tr', { 'class': 'crontab-hr' }, E('td', { 'colspan': 8 }, E('hr', { 'style': 'margin: 10px 0;' }))));
- + if (task.minute[0] == '@')
- + rows.push(this.renderAliasRow(task));
- + else
- + rows.push(this.renderTaskRow(task));
- + });
- +
- + return rows;
- + },
- +
- + renderAliasRow(task) {
- + return E('tr', { 'class': 'crontab-row', 'style': padding }, [
- + this.createTimeDropdown('minute', task.minute, 'Minute'),
- + E('td', { 'style': padding + centre }, _('-')), // Hour - empty
- + E('td', { 'style': padding + centre }, _('-')), // Day - empty
- + E('td', { 'style': padding + centre }, _('-')), // Month - empty
- + E('td', { 'style': padding + centre }, _('-')), // Weekday - empty
- + E('td', { 'style': padding + centre },
- + E('div', {}, [
- + E('label', {}, _('Command')),
- + E('input', { 'type': 'text', 'class': 'command', 'style': double_width, 'value': task.command, 'disabled': isReadonlyView }),
- + ]),
- + ),
- + E('td', { 'style': padding },
- + E('div', {}, [
- + E('label', {}, _('Comment')),
- + E('input', { 'type': 'text', 'class': 'comment', 'style': width, 'value': task.comment, 'disabled': isReadonlyView }),
- + ]),
- + ),
- + E('td', { 'style': padding }, [
- + E('button', { 'class': 'btn remove-task cbi-button-negative', 'click': ui.createHandlerFn(this, 'removeTask') }, _('Remove'))
- + ])
- + ]);
- + },
- +
- + renderTaskRow(task) {
- + return E('tr', { 'class': 'crontab-row' }, [
- + this.createTimeDropdown('minute', task.minute, 'Minute', 0, 59),
- + this.createTimeDropdown('hour', task.hour, 'Hour', 0, 23),
- + this.createTimeDropdown('day', task.day, 'Day', 0, 31),
- + this.createTimeDropdown('month', task.month, 'Month', 0, 12),
- + this.createTimeDropdown('weekday', task.weekday, 'Weekday', 0, 6),
- + E('td', { 'style': padding }, E('input', { 'type': 'text', 'class': 'command', 'style': double_width, 'value': task.command, 'disabled': isReadonlyView })),
- + E('td', { 'style': padding }, E('input', { 'type': 'text', 'class': 'comment', 'style': width, 'value': task.comment, 'disabled': isReadonlyView })),
- + E('td', { 'style': padding }, [
- + E('button', { 'class': 'btn remove-task cbi-button-negative', 'click': ui.createHandlerFn(this, 'removeTask') }, _('Remove'))
- + ])
- + ]);
- + },
- +
- + /*
- + hide the comment rows in valid fields, but don't display them.
- + */
- + renderCommentRow(task) {
- + //
- + return E('tr', { 'class': 'crontab-row', 'style': 'display: none; ' }, [
- + E('td', { 'style': padding },
- + E('div', {}, [
- + E('label', {}, _('Minute')),
- + E('input', { 'type': 'text', 'class': 'minute', 'style': width, 'value': task.comment, 'disabled': isReadonlyView }),
- + ]),
- + ),
- + E('td', { 'style': padding },
- + E('div', {}, [
- + E('label', {}, _('Comment')),
- + E('input', { 'type': 'text', 'class': 'comment', 'style': width, 'value': task.comment, 'disabled': isReadonlyView }),
- + ]),
- + ),
- + ]);
- + },
- +
- + /*
- + creates a block of entry fields customisable to the time interval type
- + */
- + createTimeDropdown(fieldName, value, label, min, max) {
- + const mode = value.includes(',') || parseInt(value, 10) >= 0 || value.startsWith('@')
- + ? (value.split(',').filter(v => v.startsWith('*/')).length > 1 || value.startsWith('@') ? 'custom' : 'specific')
- + : value.startsWith('*/')
- + ? 'interval'
- + : 'ignore';
- +
- + const intervalValue = mode === 'interval' ? value.substring(2) : '';
- + const specificValue = mode === 'specific' ? value : '';
- + const customValue = mode === 'custom' ? value : '';
- +
- + return E('td', { 'style': padding }, [
- + E('div', { 'class': 'dropdown-container' }, [
- + E('select', {
- + 'class': `${fieldName}-mode`,
- + 'style': width,
- + 'change': ev => this.updateDropdownMode(ev, fieldName)
- + }, [
- + E('option', { 'value': 'ignore', 'style': width,
- + ...(mode === 'ignore' ? { 'selected': 'true' } : {})
- + }, _('-')),
- + E('option', { 'value': 'interval', 'style': width,
- + ...(mode === 'interval' ? { 'selected': 'true' } : {})
- + }, _('Every Xth')),
- + E('option', { 'value': 'specific', 'style': width,
- + ...(mode === 'specific' ? { 'selected': 'true' } : {})
- + }, _('Specific')),
- + E('option', { 'value': 'custom', 'style': width,
- + ...(mode === 'custom' ? { 'selected': 'true' } : {})
- + }, _('Custom'))
- + ]),
- + E('div', { 'class': `${fieldName}-input ignore-input`, 'style': mode === 'ignore' ? '' : 'display:none;' }, [
- + E('input', { 'type': 'text', 'class': fieldName, 'value': '*', 'style': mode === 'ignore' ? 'display:none;': width,
- + })
- + ]),
- + E('div', { 'class': `${fieldName}-input interval-input`, 'style': mode === 'interval' ? width : 'display:none;' }, [
- + E('label', {}, _('Every')),
- + E('input', { 'type': 'number', 'min': min, 'max': max, 'class': `${fieldName}-interval`, 'value': intervalValue, 'style': width }),
- + E('span', {}, _(label.toLowerCase()))
- + ]),
- + E('div', { 'class': `${fieldName}-input specific-input`, 'style': mode === 'specific' ? width : 'display:none;' }, [
- + E('label', {}, _('At')),
- + E('input', { 'type': 'text', 'class': `${fieldName}-specific`, 'value': specificValue, 'style': width, 'placeholder': '0,5,10,...' }),
- + E('span', {}, _(label.toLowerCase() + _('s (CSV)', 'pluralisation for hours, minutes, etc')))
- + ]),
- + E('div', { 'class': `${fieldName}-input custom-input`, 'style': mode === 'custom' ? width : 'display:none;' }, [
- + E('label', {}, _('Value')),
- + E('input', { 'type': 'text', 'class': `${fieldName}-custom`, 'value': customValue, 'style': width })
- + ]),
- + ])
- + ]);
- + },
- +
- + updateDropdownMode(ev, fieldName) {
- + const dropdown = ev.target.closest('.dropdown-container');
- + const mode = ev.target.value;
- +
- + dropdown.querySelectorAll(`.${fieldName}-input`).forEach(input => {
- + input.style.display = 'none';
- + });
- +
- + dropdown.querySelector(`.${fieldName}-input.${mode}-input`).style.display = '';
- + },
- +
- + addTask(param) {
- + const tbody = document.getElementById('crontab-rows');
- +
- + let newTask
- + if (param?.type !== 'click') {
- + newTask = param;
- + } else {
- + newTask = a_task;
- + }
- + const newRows = this.renderTaskRows([newTask]);
- + newRows.forEach(row => {
- + tbody.appendChild(row);
- + })
- + },
- +
- + removeTask(ev) {
- + const row = ev.target.closest('.crontab-row');
- + const hr = row.previousElementSibling;
- + if (hr) hr.remove();
- + if (row) row.remove();
- + },
- +
- + handleSaveApply: null,
- + handleReset: null
- +});
- diff --git a/feeds/luci/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json b/feeds/luci/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json
- index ebae989d0e00..b4eba7862444 100644
- --- a/feeds/luci/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json
- +++ b/feeds/luci/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json
- @@ -98,6 +98,16 @@
- }
- },
-
- + "admin/system/crontabhelper": {
- + "action": {
- + "type": "view",
- + "path": "system/crontabhelper"
- + },
- + "depends": {
- + "acl": [ "luci-mod-system-cron" ]
- + }
- + },
- +
- "admin/system/mounts": {
- "title": "Mount Points",
- "order": 50,
- --- a/feeds/luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontab.js
- +++ b/feeds/luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontab.js
- @@ -27,6 +27,7 @@ return view.extend({
- return E([
- E('h2', _('Scheduled Tasks')),
- E('p', { 'class': 'cbi-section-descr' }, _('This is the system crontab in which scheduled tasks can be defined.')),
- + E('p', { 'class': 'cbi-section-descr' }, _('<a href="/cgi-bin/luci/admin/system/crontabhelper"> Scheduled Tasks Helper</a>')),
- E('p', {}, E('textarea', { 'style': 'width:100%', 'rows': 25, 'disabled': isReadonlyView }, [ crontab != null ? crontab : '' ]))
- ]);
- },
|