crontab.patch 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. From 388238b9baf8375e5474167c987a4a8a3358b559 Mon Sep 17 00:00:00 2001
  2. From: Paul Donald <[email protected]>
  3. Date: Wed, 23 Apr 2025 00:03:25 +0200
  4. Subject: [PATCH] luci-mod-system: give crontab a helper page
  5. Reference: https://github.com/openwrt/luci/pull/7495
  6. Signed-off-by: Paul Donald <[email protected]>
  7. ---
  8. .../resources/view/system/crontabhelper.js | 334 ++++++++++++++++++
  9. .../share/luci/menu.d/luci-mod-system.json | 14 +-
  10. 2 files changed, 347 insertions(+), 1 deletion(-)
  11. create mode 100644 feeds/luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontabhelper.js
  12. 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
  13. new file mode 100644
  14. index 000000000000..861d4d1f77a5
  15. --- /dev/null
  16. +++ b/feeds/luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontabhelper.js
  17. @@ -0,0 +1,334 @@
  18. +'use strict';
  19. +'require view';
  20. +'require fs';
  21. +'require ui';
  22. +
  23. +var isReadonlyView = !L.hasViewPermission() || null;
  24. +
  25. +
  26. +const yearly = { minute: '@yearly', command: '', comment: '', };
  27. +const monthly = { minute: '@monthly', command: '', comment: '', };
  28. +const weekly = { minute: '@weekly', command: '', comment: '', };
  29. +const daily = { minute: '@daily', command: '', comment: '', };
  30. +const hourly = { minute: '@hourly', command: '', comment: '', };
  31. +const a_task = { minute: '*', hour: '*', day: '*', month: '*', weekday: '*', command: '', comment: '', };
  32. +const alias = { minute: '@', hour: '*', day: '*', month: '*', weekday: '*', command: '', comment: '', };
  33. +
  34. +const width = 'width:100px;';
  35. +const double_width = 'width:300px';
  36. +const padding = 'padding:3px;';
  37. +const centre = 'text-align:center;';
  38. +
  39. +return view.extend({
  40. + load() {
  41. + return L.resolveDefault(fs.read('/etc/crontabs/root'), '');
  42. + },
  43. +
  44. + handleSave(ev) {
  45. + const tasks = Array.from(document.querySelectorAll('.crontab-row')).map(row => {
  46. + const getFieldValue = (fieldName) => {
  47. + const mode = row.querySelector(`.${fieldName}-mode`)?.value;
  48. +
  49. + switch (mode) {
  50. + case 'custom':
  51. + const custom = row.querySelector(`.${fieldName}-custom`)?.value?.trim();
  52. + return custom;
  53. + case 'ignore':
  54. + return '*';
  55. + case 'interval':
  56. + const interval = row.querySelector(`.${fieldName}-interval`)?.value?.trim();
  57. + return interval ? `*/${interval}` : '*'; // Every Xth unit
  58. + case 'specific':
  59. + const specific = row.querySelector(`.${fieldName}-specific`)?.value?.trim();
  60. + return specific || '*'; // Specific units
  61. + }
  62. + };
  63. +
  64. + const comment = row.querySelector('.comment')?.value?.trim();
  65. + const minute = row.querySelector('.minute')?.value?.trim();
  66. +
  67. + // if it's a # comment row, just stuff the comment and return
  68. + if (minute == comment)
  69. + return { iscomment: true, comment: comment };
  70. + else
  71. + return {
  72. + minute: getFieldValue('minute') || '*',
  73. + hour: getFieldValue('hour') || '*',
  74. + day: getFieldValue('day') || '*',
  75. + month: getFieldValue('month') || '*',
  76. + weekday: getFieldValue('weekday') || '*',
  77. + command: row.querySelector('.command')?.value?.trim(),
  78. + comment: comment ? `# ${comment}` : '',
  79. + };
  80. + });
  81. +
  82. + const value = tasks.map(task => {
  83. + if (task.iscomment)
  84. + return `${task.comment}`;
  85. + else if (task.minute[0] !== '@')
  86. + return `${task.minute} ${task.hour} ${task.day} ${task.month} ${task.weekday} ${task.command} ${task.comment}`;
  87. + else
  88. + return `${task.minute} ${task.command} ${task.comment}`;
  89. + }).join('\n') + '\n';
  90. +
  91. + return fs.write('/etc/crontabs/root', value).then(() => {
  92. + ui.addTimeLimitedNotification(null, E('p', _('Contents have been saved.')), 5000, 'info');
  93. + return fs.exec('/etc/init.d/cron', [ 'reload' ]);
  94. + }).catch(e => {
  95. + ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e.message)));
  96. + });
  97. + },
  98. +
  99. + render(crontab) {
  100. + const tasks = (crontab || '').split('\n').filter(line => line.trim()).map(line => {
  101. + if (line.startsWith('#'))
  102. + return {
  103. + // stash comment lines for saving later
  104. + minute: line,
  105. + comment: line,
  106. + };
  107. + const parts = line.split(/\s+/);
  108. + const commentIndex = parts.findIndex(part => part.startsWith('#'));
  109. + // exclude the '#' character from comments existing in valid command rows:
  110. + if (commentIndex !== -1) parts[commentIndex] = parts[commentIndex].substring(1)?.trim();
  111. + const comment = commentIndex !== -1 ? parts.slice(commentIndex).join(' ') : '';
  112. +
  113. + if(parts[0][0] == '@') {
  114. + const updatedTask = { ...alias };
  115. + updatedTask.minute = parts[0];
  116. + updatedTask.command = commentIndex !== -1 ? parts.slice(1, commentIndex).join(' ') : parts.slice(1).join(' ');
  117. + updatedTask.comment = comment;
  118. + return updatedTask;
  119. + }
  120. +
  121. + return {
  122. + minute: parts[0] || '',
  123. + hour: parts[1] || '',
  124. + day: parts[2] || '',
  125. + month: parts[3] || '',
  126. + weekday: parts[4] || '',
  127. + command: commentIndex !== -1 ? parts.slice(5, commentIndex).join(' ') : parts.slice(5).join(' ') || '',
  128. + comment: comment || '',
  129. + };
  130. + });
  131. +
  132. + return E([
  133. + E('h2', _('Scheduled Tasks')),
  134. + E('p', { 'class': 'cbi-section-descr' }, _('Define your scheduled tasks for root below.') + '<br/>' +
  135. + _('CSV - Comma Separated Value(s)')),
  136. + E('a', { 'href': 'https://openwrt.org/docs/guide-user/base-system/cron', 'target':'_blank' }, _('Crontab help wiki')),
  137. + E('table', { 'class': 'table', }, [
  138. + E('thead', {}, [
  139. + E('tr', {}, [
  140. + E('th', {}, _('Minute / Alias', 'minute or crontab alias field')),
  141. + E('th', {}, _('Hour')),
  142. + E('th', {}, _('Day')),
  143. + E('th', {}, _('Month')),
  144. + E('th', {}, _('Weekday')),
  145. + E('th', {}, _('Command')),
  146. + E('th', {}, _('Comment')),
  147. + E('th', {}, _('Action'))
  148. + ])
  149. + ]),
  150. + E('tbody', { 'id': 'crontab-rows' }, this.renderTaskRows(tasks)),
  151. + E('hr', {}),
  152. + E('tfoot', {}, [
  153. + E('tr', {}, [
  154. + E('td', { 'colspan': 1, 'style': padding }, [
  155. + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', alias ) }, _('Add alias'))
  156. + ]),
  157. + E('td', { 'colspan': 1, 'style': padding }, [
  158. + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', yearly ) }, _('Yearly task'))
  159. + ]),
  160. + E('td', { 'colspan': 1, 'style': padding }, [
  161. + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', monthly ) }, _('Monthly task'))
  162. + ]),
  163. + E('td', { 'colspan': 1, 'style': padding }, [
  164. + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', weekly ) }, _('Weekly task'))
  165. + ]),
  166. + E('td', { 'colspan': 1, 'style': padding }, [
  167. + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', daily ) }, _('Daily task'))
  168. + ]),
  169. + E('td', { 'colspan': 1, 'style': padding }, [
  170. + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask', hourly ) }, _('Hourly task'))
  171. + ]),
  172. + E('td', { 'colspan': 1, 'style': padding }, [
  173. + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'addTask') }, _('Add custom task'))
  174. + ]),
  175. + ])
  176. + ])
  177. + ])
  178. + ]);
  179. + },
  180. +
  181. + renderTaskRows(tasks) {
  182. + const rows = [];
  183. +
  184. + tasks.forEach((task, index) => {
  185. + if (task?.minute.startsWith('#') && task?.comment){
  186. + rows.push(this.renderCommentRow(task));
  187. + return;
  188. + }
  189. + rows.push(E('tr', { 'class': 'crontab-hr' }, E('td', { 'colspan': 8 }, E('hr', { 'style': 'margin: 10px 0;' }))));
  190. + if (task.minute[0] == '@')
  191. + rows.push(this.renderAliasRow(task));
  192. + else
  193. + rows.push(this.renderTaskRow(task));
  194. + });
  195. +
  196. + return rows;
  197. + },
  198. +
  199. + renderAliasRow(task) {
  200. + return E('tr', { 'class': 'crontab-row', 'style': padding }, [
  201. + this.createTimeDropdown('minute', task.minute, 'Minute'),
  202. + E('td', { 'style': padding + centre }, _('-')), // Hour - empty
  203. + E('td', { 'style': padding + centre }, _('-')), // Day - empty
  204. + E('td', { 'style': padding + centre }, _('-')), // Month - empty
  205. + E('td', { 'style': padding + centre }, _('-')), // Weekday - empty
  206. + E('td', { 'style': padding + centre },
  207. + E('div', {}, [
  208. + E('label', {}, _('Command')),
  209. + E('input', { 'type': 'text', 'class': 'command', 'style': double_width, 'value': task.command, 'disabled': isReadonlyView }),
  210. + ]),
  211. + ),
  212. + E('td', { 'style': padding },
  213. + E('div', {}, [
  214. + E('label', {}, _('Comment')),
  215. + E('input', { 'type': 'text', 'class': 'comment', 'style': width, 'value': task.comment, 'disabled': isReadonlyView }),
  216. + ]),
  217. + ),
  218. + E('td', { 'style': padding }, [
  219. + E('button', { 'class': 'btn remove-task cbi-button-negative', 'click': ui.createHandlerFn(this, 'removeTask') }, _('Remove'))
  220. + ])
  221. + ]);
  222. + },
  223. +
  224. + renderTaskRow(task) {
  225. + return E('tr', { 'class': 'crontab-row' }, [
  226. + this.createTimeDropdown('minute', task.minute, 'Minute', 0, 59),
  227. + this.createTimeDropdown('hour', task.hour, 'Hour', 0, 23),
  228. + this.createTimeDropdown('day', task.day, 'Day', 0, 31),
  229. + this.createTimeDropdown('month', task.month, 'Month', 0, 12),
  230. + this.createTimeDropdown('weekday', task.weekday, 'Weekday', 0, 6),
  231. + E('td', { 'style': padding }, E('input', { 'type': 'text', 'class': 'command', 'style': double_width, 'value': task.command, 'disabled': isReadonlyView })),
  232. + E('td', { 'style': padding }, E('input', { 'type': 'text', 'class': 'comment', 'style': width, 'value': task.comment, 'disabled': isReadonlyView })),
  233. + E('td', { 'style': padding }, [
  234. + E('button', { 'class': 'btn remove-task cbi-button-negative', 'click': ui.createHandlerFn(this, 'removeTask') }, _('Remove'))
  235. + ])
  236. + ]);
  237. + },
  238. +
  239. + /*
  240. + hide the comment rows in valid fields, but don't display them.
  241. + */
  242. + renderCommentRow(task) {
  243. + //
  244. + return E('tr', { 'class': 'crontab-row', 'style': 'display: none; ' }, [
  245. + E('td', { 'style': padding },
  246. + E('div', {}, [
  247. + E('label', {}, _('Minute')),
  248. + E('input', { 'type': 'text', 'class': 'minute', 'style': width, 'value': task.comment, 'disabled': isReadonlyView }),
  249. + ]),
  250. + ),
  251. + E('td', { 'style': padding },
  252. + E('div', {}, [
  253. + E('label', {}, _('Comment')),
  254. + E('input', { 'type': 'text', 'class': 'comment', 'style': width, 'value': task.comment, 'disabled': isReadonlyView }),
  255. + ]),
  256. + ),
  257. + ]);
  258. + },
  259. +
  260. + /*
  261. + creates a block of entry fields customisable to the time interval type
  262. + */
  263. + createTimeDropdown(fieldName, value, label, min, max) {
  264. + const mode = value.includes(',') || parseInt(value, 10) >= 0 || value.startsWith('@')
  265. + ? (value.split(',').filter(v => v.startsWith('*/')).length > 1 || value.startsWith('@') ? 'custom' : 'specific')
  266. + : value.startsWith('*/')
  267. + ? 'interval'
  268. + : 'ignore';
  269. +
  270. + const intervalValue = mode === 'interval' ? value.substring(2) : '';
  271. + const specificValue = mode === 'specific' ? value : '';
  272. + const customValue = mode === 'custom' ? value : '';
  273. +
  274. + return E('td', { 'style': padding }, [
  275. + E('div', { 'class': 'dropdown-container' }, [
  276. + E('select', {
  277. + 'class': `${fieldName}-mode`,
  278. + 'style': width,
  279. + 'change': ev => this.updateDropdownMode(ev, fieldName)
  280. + }, [
  281. + E('option', { 'value': 'ignore', 'style': width,
  282. + ...(mode === 'ignore' ? { 'selected': 'true' } : {})
  283. + }, _('-')),
  284. + E('option', { 'value': 'interval', 'style': width,
  285. + ...(mode === 'interval' ? { 'selected': 'true' } : {})
  286. + }, _('Every Xth')),
  287. + E('option', { 'value': 'specific', 'style': width,
  288. + ...(mode === 'specific' ? { 'selected': 'true' } : {})
  289. + }, _('Specific')),
  290. + E('option', { 'value': 'custom', 'style': width,
  291. + ...(mode === 'custom' ? { 'selected': 'true' } : {})
  292. + }, _('Custom'))
  293. + ]),
  294. + E('div', { 'class': `${fieldName}-input ignore-input`, 'style': mode === 'ignore' ? '' : 'display:none;' }, [
  295. + E('input', { 'type': 'text', 'class': fieldName, 'value': '*', 'style': mode === 'ignore' ? 'display:none;': width,
  296. + })
  297. + ]),
  298. + E('div', { 'class': `${fieldName}-input interval-input`, 'style': mode === 'interval' ? width : 'display:none;' }, [
  299. + E('label', {}, _('Every')),
  300. + E('input', { 'type': 'number', 'min': min, 'max': max, 'class': `${fieldName}-interval`, 'value': intervalValue, 'style': width }),
  301. + E('span', {}, _(label.toLowerCase()))
  302. + ]),
  303. + E('div', { 'class': `${fieldName}-input specific-input`, 'style': mode === 'specific' ? width : 'display:none;' }, [
  304. + E('label', {}, _('At')),
  305. + E('input', { 'type': 'text', 'class': `${fieldName}-specific`, 'value': specificValue, 'style': width, 'placeholder': '0,5,10,...' }),
  306. + E('span', {}, _(label.toLowerCase() + _('s (CSV)', 'pluralisation for hours, minutes, etc')))
  307. + ]),
  308. + E('div', { 'class': `${fieldName}-input custom-input`, 'style': mode === 'custom' ? width : 'display:none;' }, [
  309. + E('label', {}, _('Value')),
  310. + E('input', { 'type': 'text', 'class': `${fieldName}-custom`, 'value': customValue, 'style': width })
  311. + ]),
  312. + ])
  313. + ]);
  314. + },
  315. +
  316. + updateDropdownMode(ev, fieldName) {
  317. + const dropdown = ev.target.closest('.dropdown-container');
  318. + const mode = ev.target.value;
  319. +
  320. + dropdown.querySelectorAll(`.${fieldName}-input`).forEach(input => {
  321. + input.style.display = 'none';
  322. + });
  323. +
  324. + dropdown.querySelector(`.${fieldName}-input.${mode}-input`).style.display = '';
  325. + },
  326. +
  327. + addTask(param) {
  328. + const tbody = document.getElementById('crontab-rows');
  329. +
  330. + let newTask
  331. + if (param?.type !== 'click') {
  332. + newTask = param;
  333. + } else {
  334. + newTask = a_task;
  335. + }
  336. + const newRows = this.renderTaskRows([newTask]);
  337. + newRows.forEach(row => {
  338. + tbody.appendChild(row);
  339. + })
  340. + },
  341. +
  342. + removeTask(ev) {
  343. + const row = ev.target.closest('.crontab-row');
  344. + const hr = row.previousElementSibling;
  345. + if (hr) hr.remove();
  346. + if (row) row.remove();
  347. + },
  348. +
  349. + handleSaveApply: null,
  350. + handleReset: null
  351. +});
  352. 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
  353. index ebae989d0e00..b4eba7862444 100644
  354. --- a/feeds/luci/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json
  355. +++ b/feeds/luci/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json
  356. @@ -98,6 +98,16 @@
  357. }
  358. },
  359. + "admin/system/crontabhelper": {
  360. + "action": {
  361. + "type": "view",
  362. + "path": "system/crontabhelper"
  363. + },
  364. + "depends": {
  365. + "acl": [ "luci-mod-system-cron" ]
  366. + }
  367. + },
  368. +
  369. "admin/system/mounts": {
  370. "title": "Mount Points",
  371. "order": 50,
  372. --- a/feeds/luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontab.js
  373. +++ b/feeds/luci/modules/luci-mod-system/htdocs/luci-static/resources/view/system/crontab.js
  374. @@ -27,6 +27,7 @@ return view.extend({
  375. return E([
  376. E('h2', _('Scheduled Tasks')),
  377. E('p', { 'class': 'cbi-section-descr' }, _('This is the system crontab in which scheduled tasks can be defined.')),
  378. + E('p', { 'class': 'cbi-section-descr' }, _('<a href="/cgi-bin/luci/admin/system/crontabhelper"> Scheduled Tasks Helper</a>')),
  379. E('p', {}, E('textarea', { 'style': 'width:100%', 'rows': 25, 'disabled': isReadonlyView }, [ crontab != null ? crontab : '' ]))
  380. ]);
  381. },