|
5 | 5 |
|
6 | 6 | var isReadonlyView = !L.hasViewPermission() || null;
|
7 | 7 |
|
| 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 | + |
8 | 20 | return view.extend({
|
9 |
| - load: function() { |
| 21 | + load() { |
10 | 22 | return L.resolveDefault(fs.read('/etc/crontabs/root'), '');
|
11 | 23 | },
|
12 | 24 |
|
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; |
15 | 29 |
|
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 | + }; |
19 | 44 |
|
| 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'); |
20 | 66 | return fs.exec('/etc/init.d/cron', [ 'reload' ]);
|
21 |
| - }).catch(function(e) { |
| 67 | + }).catch(e => { |
22 | 68 | ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e.message)));
|
23 | 69 | });
|
24 | 70 | },
|
25 | 71 |
|
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 | + |
27 | 99 | return E([
|
28 | 100 | 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 | + ]) |
31 | 144 | ]);
|
32 | 145 | },
|
33 | 146 |
|
| 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 | + |
34 | 283 | handleSaveApply: null,
|
35 | 284 | handleReset: null
|
36 | 285 | });
|
0 commit comments