|
@@ -0,0 +1,377 @@
|
|
|
+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,18 @@
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
++ "admin/system/crontabhelper": {
|
|
|
++ "title": "Scheduled Tasks Helper",
|
|
|
++ "order": 47,
|
|
|
++ "action": {
|
|
|
++ "type": "view",
|
|
|
++ "path": "system/crontabhelper"
|
|
|
++ },
|
|
|
++ "depends": {
|
|
|
++ "acl": [ "luci-mod-system-cron" ]
|
|
|
++ }
|
|
|
++ },
|
|
|
++
|
|
|
+ "admin/system/mounts": {
|
|
|
+ "title": "Mount Points",
|
|
|
+ "order": 50,
|