diff --git a/FEATURES.md b/FEATURES.md index 7b299bc..3ee042c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -46,6 +46,7 @@ Get notified when tasks succeed or fail. Three notification channels available: "enabled": true, "type": "webhook", "target": "https://your-webhook.example.com/hook", + "webhook_format": "generic", "on_success": false, "on_failure": true }, { @@ -68,7 +69,18 @@ Configure once in Settings (gear icon), applies to all tasks: - `PUT /cron/settings` — set bot token, chat ID, trigger conditions - `POST /cron/settings/test-telegram` — send a test message -**Webhook payload:** +**Webhook format** (`webhook_format` field — select in UI or set via API): + +| Format | Value | Payload | +|--------|-------|---------| +| Generic | `generic` (default) | Nested JSON with `event`, `task`, `result`, `timestamp` | +| n8n | `n8n` | Flat JSON: `event`, `task_id`, `task_name`, `command`, `success`, `message`, `duration_ms`, `timestamp` | +| Discord | `discord` | `{"content": "✅ SUCCESS — TaskName (1250ms)\n```output```"}` | +| Slack | `slack` | `{"text": "..."}` — same message as Discord | +| Home Assistant | `home_assistant` | `{"message", "task_name", "success", "duration_ms", "output", "timestamp"}` | +| Uptime Kuma | `uptime_kuma` | Query params on push URL: `?status=up\|down&msg=...&ping=duration_ms` | + +**Generic webhook payload:** ```json { "event": "task_completed", @@ -78,8 +90,6 @@ Configure once in Settings (gear icon), applies to all tasks: } ``` -Works with n8n, Home Assistant, Discord webhooks, Slack, Uptime Kuma, or any HTTP endpoint. - ### 4. Categories, Tags & Priority Organize tasks with metadata: @@ -261,16 +271,23 @@ go run ./cmd/cron - Environment variables (key-value editor) - Max log entries - Task dependencies (select from existing tasks) - - Webhook notification URL + triggers + - Webhook type (Generic, n8n, Discord, Slack, Home Assistant, Uptime Kuma) + URL + triggers 6. Click **Create** +### Editing a Task + +1. Click **Edit** on any task row +2. The create form opens pre-filled with current settings +3. Modify fields and click **Save** (`PUT /cron/tasks/{id}`) + ### Task List - **Filter** by category or tag using the dropdowns above the table - **Run Once** — trigger immediate execution - **Pause/Resume** — toggle the schedule - **Show Logs** — expand inline log viewer with search, CSV/JSON export -- **Delete** — remove the task +- **Edit** — modify task settings +- **Delete** — remove the task (confirmation dialog) ### Log Viewer @@ -294,6 +311,7 @@ Base path: `/cron` | `GET` | `/tasks?tag=X` | Filter by tag | | `POST` | `/tasks` | Create a task | | `GET` | `/tasks/{id}` | Get single task | +| `PUT` | `/tasks/{id}` | Update a task | | `DELETE` | `/tasks/{id}` | Delete a task | | `POST` | `/tasks/{id}/run` | Run task once | | `POST` | `/tasks/{id}/toggle` | Pause/resume task | diff --git a/app.js b/app.js index 945002b..dc332af 100644 --- a/app.js +++ b/app.js @@ -1,8 +1,14 @@ -const API_BASE=(window.API_BASE||'').replace(/\/$/,'');let tasks=[];const i18n={zh:{title:'定时管理器',header:'定时管理器',lang:'语言',taskList:'任务列表',runAll:'全部运行一次',newTask:'新建任务',colName:'名称',colCmd:'命令',colStatus:'状态',colNext:'下次执行',colLast:'上次结果',colActions:'操作',createTitle:'创建任务',name:'任务名称',cmd:'执行命令',plan:'计划类型',optInterval:'间隔分钟',optCron:'cron 表达式',interval:'执行间隔(分钟)',cron:'cron 表达式',cancel:'取消',create:'创建',phName:'例如:每日备份',phCmd:'例如:bash /path/to/script.sh',phInterval:'例如:60',phCron:'例如:*/5 * * * *',statusRunning:'运行中',statusPaused:'已暂停',statusExecuting:'执行中...',btnRun:'运行一次',btnPause:'暂停',btnResume:'恢复',btnLogsShow:'查看日志',btnLogsHide:'隐藏日志',logsTitle:'日志 · ',btnClearLogs:'清空日志',btnDelete:'删除任务',advancedOptions:'高级选项',timeout:'超时(秒)',retryCount:'重试次数',retryDelay:'重试间隔(秒)',envVars:'环境变量',addEnv:'+ 添加变量',phTimeout:'120',phRetryCount:'0',phRetryDelay:'10',envKey:'键',envValue:'值',removeEnv:'删除',webhookLabel:'Webhook 通知',phWebhookUrl:'https://example.com/webhook',onSuccess:'成功时',onFailure:'失败时',category:'分类',tags:'标签(逗号分隔)',priority:'优先级(1-10)',phCategory:'例如:备份',phTags:'例如:关键, 每日',phPriority:'5',filterAll:'全部分类',filterAllTags:'全部标签',dependsOn:'依赖任务',allowParallel:'允许并行执行',depSkipped:'依赖未满足',logSearch:'搜索日志...',exportCsv:'导出CSV',exportJson:'导出JSON',maxLogEntries:'最大日志数',phMaxLogs:'100',cronValid:'表达式有效',cronInvalid:'表达式无效',nextRuns:'接下来执行:',template:'从模板开始',noTemplate:'-- 空白任务 --',emailLabel:'邮件通知 (SMTP)',phEmailTo:'收件人@example.com',settingsTitle:'设置',settingsDesc:'配置全局通知设置。Telegram通知适用于所有任务。',tgBotToken:'Telegram Bot Token',tgChatId:'Telegram Chat ID',tgTest:'测试',save:'保存',tgTestOk:'消息已发送!',tgTestFail:'发送失败'},en:{title:'Scheduler',header:'Scheduler',lang:'Language',taskList:'Tasks',runAll:'Run All Once',newTask:'New Task',colName:'Name',colCmd:'Command',colStatus:'Status',colNext:'Next Run',colLast:'Last Result',colActions:'Actions',createTitle:'Create Task',name:'Task Name',cmd:'Command',plan:'Schedule Type',optInterval:'Interval (minutes)',optCron:'Cron Expression',interval:'Interval (minutes)',cron:'Cron Expression',cancel:'Cancel',create:'Create',phName:'e.g. Daily Backup',phCmd:'e.g. bash /path/to/script.sh',phInterval:'e.g. 60',phCron:'e.g. */5 * * * *',statusRunning:'Running',statusPaused:'Paused',statusExecuting:'Executing...',btnRun:'Run Once',btnPause:'Pause',btnResume:'Resume',btnLogsShow:'Show Logs',btnLogsHide:'Hide Logs',logsTitle:'Logs · ',btnClearLogs:'Clear Logs',btnDelete:'Delete Task',advancedOptions:'Advanced Options',timeout:'Timeout (seconds)',retryCount:'Retry Count',retryDelay:'Retry Delay (seconds)',envVars:'Environment Variables',addEnv:'+ Add Variable',phTimeout:'120',phRetryCount:'0',phRetryDelay:'10',envKey:'Key',envValue:'Value',removeEnv:'Remove',webhookLabel:'Webhook Notification',phWebhookUrl:'https://example.com/webhook',onSuccess:'On Success',onFailure:'On Failure',category:'Category',tags:'Tags (comma-separated)',priority:'Priority (1-10)',phCategory:'e.g. backup',phTags:'e.g. critical, daily',phPriority:'5',filterAll:'All Categories',filterAllTags:'All Tags',dependsOn:'Depends On',allowParallel:'Allow parallel execution',depSkipped:'Dep not met',logSearch:'Search logs...',exportCsv:'Export CSV',exportJson:'Export JSON',maxLogEntries:'Max Log Entries',phMaxLogs:'100',cronValid:'Valid expression',cronInvalid:'Invalid expression',nextRuns:'Next runs:',template:'Start from Template',noTemplate:'-- Blank Task --',emailLabel:'Email Notification (SMTP)',phEmailTo:'recipient@example.com',settingsTitle:'Settings',settingsDesc:'Configure global notification settings. Telegram notifications apply to all tasks.',tgBotToken:'Telegram Bot Token',tgChatId:'Telegram Chat ID',tgTest:'Test',save:'Save',tgTestOk:'Message sent!',tgTestFail:'Send failed'}};let lang='en';function byId(id){return document.getElementById(id)}function fmtTime(ts){if(!ts)return '-';const d=new Date(ts);const p=n=>String(n).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`}function escapeHtml(str){return str.replace(/[&<>"]/g,s=>({'&':'&','<':'<','>':'>','"':'"'}[s]))}function isValidCron(expr){return expr.trim().split(/\s+/).length===5} +const API_BASE=(window.API_BASE||'').replace(/\/$/,'');let tasks=[];const i18n={zh:{title:'定时管理器',header:'定时管理器',lang:'语言',taskList:'任务列表',runAll:'全部运行一次',newTask:'新建任务',colName:'名称',colCmd:'命令',colStatus:'状态',colNext:'下次执行',colLast:'上次结果',colActions:'操作',createTitle:'创建任务',name:'任务名称',cmd:'执行命令',plan:'计划类型',optInterval:'间隔分钟',optCron:'cron 表达式',interval:'执行间隔(分钟)',cron:'cron 表达式',cancel:'取消',create:'创建',phName:'例如:每日备份',phCmd:'例如:bash /path/to/script.sh',phInterval:'例如:60',phCron:'例如:*/5 * * * *',statusRunning:'运行中',statusPaused:'已暂停',statusExecuting:'执行中...',btnRun:'运行一次',btnPause:'暂停',btnResume:'恢复',btnLogsShow:'查看日志',btnLogsHide:'隐藏日志',logsTitle:'日志 · ',btnClearLogs:'清空日志',btnDelete:'删除任务',advancedOptions:'高级选项',timeout:'超时(秒)',retryCount:'重试次数',retryDelay:'重试间隔(秒)',envVars:'环境变量',addEnv:'+ 添加变量',phTimeout:'120',phRetryCount:'0',phRetryDelay:'10',envKey:'键',envValue:'值',removeEnv:'删除',webhookLabel:'Webhook 通知',phWebhookUrl:'https://example.com/webhook',onSuccess:'成功时',onFailure:'失败时',category:'分类',tags:'标签(逗号分隔)',priority:'优先级(1-10)',phCategory:'例如:备份',phTags:'例如:关键, 每日',phPriority:'5',filterAll:'全部分类',filterAllTags:'全部标签',dependsOn:'依赖任务',allowParallel:'允许并行执行',depSkipped:'依赖未满足',logSearch:'搜索日志...',exportCsv:'导出CSV',exportJson:'导出JSON',maxLogEntries:'最大日志数',phMaxLogs:'100',cronValid:'表达式有效',cronInvalid:'表达式无效',nextRuns:'接下来执行:',template:'从模板开始',noTemplate:'-- 空白任务 --',emailLabel:'邮件通知 (SMTP)',phEmailTo:'收件人@example.com',settingsTitle:'设置',settingsDesc:'配置全局通知设置。Telegram通知适用于所有任务。',tgBotToken:'Telegram Bot Token',tgChatId:'Telegram Chat ID',tgTest:'测试',save:'保存',tgTestOk:'消息已发送!',tgTestFail:'发送失败',btnEdit:'编辑',editTitle:'编辑任务',save:'保存',webhookFormat:'Webhook 类型',webhookGeneric:'通用 (JSON)',webhookN8n:'n8n',webhookDiscord:'Discord',webhookSlack:'Slack',webhookHA:'Home Assistant',webhookKuma:'Uptime Kuma',deleteConfirmTitle:'删除任务?',deleteConfirmMsg:'确定要删除任务 "{name}" 吗?此操作无法撤销。',deleteConfirmBtn:'删除'},en:{title:'Scheduler',header:'Scheduler',lang:'Language',taskList:'Tasks',runAll:'Run All Once',newTask:'New Task',colName:'Name',colCmd:'Command',colStatus:'Status',colNext:'Next Run',colLast:'Last Result',colActions:'Actions',createTitle:'Create Task',name:'Task Name',cmd:'Command',plan:'Schedule Type',optInterval:'Interval (minutes)',optCron:'Cron Expression',interval:'Interval (minutes)',cron:'Cron Expression',cancel:'Cancel',create:'Create',phName:'e.g. Daily Backup',phCmd:'e.g. bash /path/to/script.sh',phInterval:'e.g. 60',phCron:'e.g. */5 * * * *',statusRunning:'Running',statusPaused:'Paused',statusExecuting:'Executing...',btnRun:'Run Once',btnPause:'Pause',btnResume:'Resume',btnLogsShow:'Show Logs',btnLogsHide:'Hide Logs',logsTitle:'Logs · ',btnClearLogs:'Clear Logs',btnDelete:'Delete Task',advancedOptions:'Advanced Options',timeout:'Timeout (seconds)',retryCount:'Retry Count',retryDelay:'Retry Delay (seconds)',envVars:'Environment Variables',addEnv:'+ Add Variable',phTimeout:'120',phRetryCount:'0',phRetryDelay:'10',envKey:'Key',envValue:'Value',removeEnv:'Remove',webhookLabel:'Webhook Notification',phWebhookUrl:'https://example.com/webhook',onSuccess:'On Success',onFailure:'On Failure',category:'Category',tags:'Tags (comma-separated)',priority:'Priority (1-10)',phCategory:'e.g. backup',phTags:'e.g. critical, daily',phPriority:'5',filterAll:'All Categories',filterAllTags:'All Tags',dependsOn:'Depends On',allowParallel:'Allow parallel execution',depSkipped:'Dep not met',logSearch:'Search logs...',exportCsv:'Export CSV',exportJson:'Export JSON',maxLogEntries:'Max Log Entries',phMaxLogs:'100',cronValid:'Valid expression',cronInvalid:'Invalid expression',nextRuns:'Next runs:',template:'Start from Template',noTemplate:'-- Blank Task --',emailLabel:'Email Notification (SMTP)',phEmailTo:'recipient@example.com',settingsTitle:'Settings',settingsDesc:'Configure global notification settings. Telegram notifications apply to all tasks.',tgBotToken:'Telegram Bot Token',tgChatId:'Telegram Chat ID',tgTest:'Test',save:'Save',tgTestOk:'Message sent!',tgTestFail:'Send failed',btnEdit:'Edit',editTitle:'Edit Task',save:'Save',webhookFormat:'Webhook Type',webhookGeneric:'Generic (JSON)',webhookN8n:'n8n',webhookDiscord:'Discord',webhookSlack:'Slack',webhookHA:'Home Assistant',webhookKuma:'Uptime Kuma',deleteConfirmTitle:'Delete Task?',deleteConfirmMsg:'Are you sure you want to delete "{name}"? This cannot be undone.',deleteConfirmBtn:'Delete'}};let lang='en';let editingTaskId=null;let confirmCallback=null;function byId(id){return document.getElementById(id)}function fmtTime(ts){if(!ts)return '-';const d=new Date(ts);const p=n=>String(n).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`}function escapeHtml(str){return str.replace(/[&<>"]/g,s=>({'&':'&','<':'<','>':'>','"':'"'}[s]))}function isValidCron(expr){return expr.trim().split(/\s+/).length===5} function applyI18n(){const t=i18n[lang];if(t.wordSuccess===undefined){t.wordSuccess='Success'}if(t.wordFail===undefined){t.wordFail='Fail'}document.querySelectorAll('[data-i18n]').forEach(el=>{const k=el.getAttribute('data-i18n');if(t[k])el.textContent=t[k]});document.querySelectorAll('[data-i18n-ph]').forEach(el=>{const k=el.getAttribute('data-i18n-ph');if(t[k])el.setAttribute('placeholder',t[k])});document.querySelectorAll('[data-i18n-opt]').forEach(el=>{const k=el.getAttribute('data-i18n-opt');if(t[k])el.textContent=t[k]});document.title=t.title} function addEnvRow(){const container=byId('envContainer');const row=document.createElement('div');row.className='env-row';row.innerHTML='';row.querySelector('.env-remove').addEventListener('click',()=>row.remove());container.appendChild(row)} function getEnvVars(){const rows=byId('envContainer').querySelectorAll('.env-row');const env={};rows.forEach(r=>{const k=r.querySelector('.env-key').value.trim();const v=r.querySelector('.env-val').value.trim();if(k)env[k]=v});return Object.keys(env).length?env:undefined} function clearEnvRows(){byId('envContainer').innerHTML=''} +function showConfirm(message,onConfirm){confirmCallback=onConfirm;byId('confirmMessage').textContent=message;byId('confirmModal').classList.add('show')} +function hideConfirm(){byId('confirmModal').classList.remove('show');confirmCallback=null} +function populateDependsOn(excludeId){const depSel=byId('dependsOnSelect');depSel.innerHTML='';tasks.forEach(t=>{if(excludeId&&t.id===excludeId)return;const o=document.createElement('option');o.value=t.id;o.textContent=t.name;depSel.appendChild(o)})} +function clearForm(){editingTaskId=null;byId('taskNameInput').value='';byId('commandInput').value='';byId('intervalInput').value='';byId('cronInput').value='';byId('timeoutInput').value='';byId('retryCountInput').value='';byId('retryDelayInput').value='';byId('categoryInput').value='';byId('tagsInput').value='';byId('priorityInput').value='';byId('maxLogEntriesInput').value='';byId('dependsOnSelect').selectedIndex=-1;byId('allowParallelCheck').checked=false;byId('webhookUrlInput').value='';byId('webhookFormatSelect').value='generic';byId('notifyOnSuccess').checked=false;byId('notifyOnFailure').checked=true;byId('emailToInput').value='';byId('smtpHostInput').value='';byId('smtpPortInput').value='';byId('smtpUserInput').value='';byId('smtpPassInput').value='';byId('emailOnSuccess').checked=false;byId('emailOnFailure').checked=true;byId('templateSelect').value='';byId('cronFeedback').className='cron-feedback';byId('cronFeedback').innerHTML='';clearEnvRows();const tdict=i18n[lang];byId('taskModalTitle').textContent=tdict.createTitle;byId('createTaskBtn').textContent=tdict.create;byId('templateRow').classList.remove('hidden')} +function populateForm(task){const tdict=i18n[lang];byId('taskNameInput').value=task.name||'';byId('commandInput').value=task.command||'';const st=byId('scheduleTypeSelect');st.value=task.type||'interval';st.dispatchEvent(new Event('change'));if(task.type==='interval'&&task.interval_ms){byId('intervalInput').value=Math.round(task.interval_ms/6e10)}if(task.type==='cron'&&task.cron_expr){byId('cronInput').value=task.cron_expr;validateCronInput()}if(task.timeout_sec)byId('timeoutInput').value=task.timeout_sec;if(task.retry_count)byId('retryCountInput').value=task.retry_count;if(task.retry_delay_sec)byId('retryDelayInput').value=task.retry_delay_sec;byId('categoryInput').value=task.category||'';byId('tagsInput').value=(task.tags||[]).join(', ');if(task.priority)byId('priorityInput').value=task.priority;if(task.max_log_entries)byId('maxLogEntriesInput').value=task.max_log_entries;byId('allowParallelCheck').checked=!!task.allow_parallel;clearEnvRows();if(task.env){Object.entries(task.env).forEach(([k,v])=>{addEnvRow();const rows=byId('envContainer').querySelectorAll('.env-row');const row=rows[rows.length-1];row.querySelector('.env-key').value=k;row.querySelector('.env-val').value=v})}const webhook=(task.notifications||[]).find(n=>n.type==='webhook');if(webhook){byId('webhookUrlInput').value=webhook.target||'';byId('webhookFormatSelect').value=webhook.webhook_format||'generic';byId('notifyOnSuccess').checked=!!webhook.on_success;byId('notifyOnFailure').checked=webhook.on_failure!==false}else{byId('webhookUrlInput').value='';byId('webhookFormatSelect').value='generic';byId('notifyOnSuccess').checked=false;byId('notifyOnFailure').checked=true}const email=(task.notifications||[]).find(n=>n.type==='email');if(email){byId('emailToInput').value=email.target||'';byId('smtpHostInput').value=email.smtp_host||'';byId('smtpPortInput').value=email.smtp_port||'';byId('smtpUserInput').value=email.smtp_user||'';byId('smtpPassInput').value=email.smtp_pass||'';byId('emailOnSuccess').checked=!!email.on_success;byId('emailOnFailure').checked=email.on_failure!==false}else{byId('emailToInput').value='';byId('smtpHostInput').value='';byId('smtpPortInput').value='';byId('smtpUserInput').value='';byId('smtpPassInput').value='';byId('emailOnSuccess').checked=false;byId('emailOnFailure').checked=true}populateDependsOn(task.id);(task.depends_on||[]).forEach(depId=>{const opt=byId('dependsOnSelect').querySelector(`option[value="${depId}"]`);if(opt)opt.selected=true});byId('taskModalTitle').textContent=tdict.editTitle;byId('createTaskBtn').textContent=tdict.save;byId('templateRow').classList.add('hidden')} +function buildSavePayload(){const name=byId('taskNameInput').value.trim();const command=byId('commandInput').value.trim();const scheduleType=byId('scheduleTypeSelect').value;const intervalMin=parseInt(byId('intervalInput').value,10);const cronExpr=byId('cronInput').value.trim();const timeoutSec=parseInt(byId('timeoutInput').value,10)||0;const retryCount=parseInt(byId('retryCountInput').value,10)||0;const retryDelaySec=parseInt(byId('retryDelayInput').value,10)||0;const env=getEnvVars();if(!name||!command)return null;if(scheduleType==='interval'){if(!intervalMin||intervalMin<1)return null}else{if(!isValidCron(cronExpr))return null}const category=byId('categoryInput').value.trim();const tagsRaw=byId('tagsInput').value.trim();const tags=tagsRaw?tagsRaw.split(',').map(s=>s.trim()).filter(Boolean):[];const priority=parseInt(byId('priorityInput').value,10)||0;const webhookUrl=byId('webhookUrlInput').value.trim();const onSuccess=byId('notifyOnSuccess').checked;const onFailure=byId('notifyOnFailure').checked;const payload={name,command,type:scheduleType,interval_min:intervalMin,cron_expr:cronExpr,timeout_sec:timeoutSec,retry_count:retryCount,retry_delay_sec:retryDelaySec};const dependsOn=Array.from(byId('dependsOnSelect').selectedOptions).map(o=>o.value);const allowParallel=byId('allowParallelCheck').checked;if(category)payload.category=category;if(tags.length)payload.tags=tags;if(priority)payload.priority=priority;const maxLogEntries=parseInt(byId('maxLogEntriesInput').value,10)||0;if(dependsOn.length)payload.depends_on=dependsOn;if(allowParallel)payload.allow_parallel=true;if(maxLogEntries>0)payload.max_log_entries=maxLogEntries;if(env)payload.env=env;const notifications=[];if(webhookUrl){const wh={enabled:true,type:'webhook',target:webhookUrl,on_success:onSuccess,on_failure:onFailure};const fmt=byId('webhookFormatSelect').value;if(fmt&&fmt!=='generic')wh.webhook_format=fmt;notifications.push(wh)}const emailTo=byId('emailToInput').value.trim();const smtpHost=byId('smtpHostInput').value.trim();if(emailTo&&smtpHost){const em={enabled:true,type:'email',target:emailTo,on_success:byId('emailOnSuccess').checked,on_failure:byId('emailOnFailure').checked,smtp_host:smtpHost,smtp_port:parseInt(byId('smtpPortInput').value,10)||587,smtp_user:byId('smtpUserInput').value.trim()};const smtpPass=byId('smtpPassInput').value;if(smtpPass)em.smtp_pass=smtpPass;notifications.push(em)}payload.notifications=notifications;return payload} // --- Theme Toggle --- function initTheme(){const saved=localStorage.getItem('cron_theme');if(saved==='light')document.documentElement.setAttribute('data-theme','light');updateThemeIcon()} function toggleTheme(){const cur=document.documentElement.getAttribute('data-theme');if(cur==='light'){document.documentElement.removeAttribute('data-theme');localStorage.setItem('cron_theme','dark')}else{document.documentElement.setAttribute('data-theme','light');localStorage.setItem('cron_theme','light')}updateThemeIcon()} @@ -20,9 +26,10 @@ function renderExecChart(logs){if(!logs||logs.length<2)return '';const maxBars=3 let cronValidateTimer=null; function validateCronInput(){const expr=byId('cronInput').value.trim();const fb=byId('cronFeedback');if(!expr){fb.className='cron-feedback';fb.innerHTML='';return}if(cronValidateTimer)clearTimeout(cronValidateTimer);cronValidateTimer=setTimeout(()=>{fetch(`${API_BASE}/cron/cron/validate`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({expr})}).then(r=>r.json()).then(d=>{const tdict=i18n[lang];if(d.valid){let html='✓ '+(tdict.cronValid||'Valid')+'';if(d.next_runs&&d.next_runs.length){html+='
'+(tdict.nextRuns||'Next:')+' '+d.next_runs.map(ts=>fmtTime(ts)).join(', ')+'
'}fb.className='cron-feedback valid';fb.innerHTML=html}else{const msgs=d.errors.map(e=>'
'+escapeHtml(e.field)+': '+escapeHtml(e.message)+'
').join('');fb.className='cron-feedback invalid';fb.innerHTML='✗ '+(tdict.cronInvalid||'Invalid')+''+msgs}}).catch(()=>{fb.className='cron-feedback';fb.innerHTML=''})},300)} function updateFilterOptions(){const tdict=i18n[lang];fetch(`${API_BASE}/cron/categories`).then(r=>r.json()).then(cats=>{const sel=byId('filterCategory');const cur=sel.value;sel.innerHTML='';(cats||[]).forEach(c=>{const o=document.createElement('option');o.value=c;o.textContent=c;sel.appendChild(o)});sel.value=cur});fetch(`${API_BASE}/cron/tags`).then(r=>r.json()).then(tags=>{const sel=byId('filterTag');const cur=sel.value;sel.innerHTML='';(tags||[]).forEach(t=>{const o=document.createElement('option');o.value=t;o.textContent=t;sel.appendChild(o)});sel.value=cur});fetch(`${API_BASE}/cron/categories`).then(r=>r.json()).then(cats=>{const dl=byId('categoryList');dl.innerHTML='';(cats||[]).forEach(c=>{const o=document.createElement('option');o.value=c;dl.appendChild(o)})})} -function init(){const modal=byId('createTaskModal');const openBtn=byId('openCreateModalBtn');const cancelBtn=byId('cancelCreateBtn');const createBtn=byId('createTaskBtn');const scheduleTypeSelect=byId('scheduleTypeSelect');const rowInterval=byId('rowInterval');const rowCron=byId('rowCron');const langSelect=byId('langSelect');lang='en';langSelect.value=lang;applyI18n();function openCreate(){const depSel=byId('dependsOnSelect');depSel.innerHTML='';tasks.forEach(t=>{const o=document.createElement('option');o.value=t.id;o.textContent=t.name;depSel.appendChild(o)});modal.classList.add('show')}function closeCreate(){modal.classList.remove('show')}function updateVisibility(){const t=scheduleTypeSelect.value;if(t==='cron'){rowInterval.classList.add('hidden');rowCron.classList.remove('hidden')}else{rowInterval.classList.remove('hidden');rowCron.classList.add('hidden')}}openBtn.addEventListener('click',openCreate);cancelBtn.addEventListener('click',closeCreate);createBtn.addEventListener('click',()=>{createTask().then(closeCreate)});byId('runAllBtn').addEventListener('click',runAllOnce);byId('addEnvBtn').addEventListener('click',addEnvRow);byId('cronInput').addEventListener('input',validateCronInput);byId('filterCategory').addEventListener('change',fetchTasks);byId('filterTag').addEventListener('change',fetchTasks);scheduleTypeSelect.addEventListener('change',updateVisibility);langSelect.addEventListener('change',()=>{lang=langSelect.value;applyI18n();renderTasks()});byId('themeToggle').addEventListener('click',toggleTheme);byId('templateSelect').addEventListener('change',applyTemplate);byId('settingsToggle').addEventListener('click',()=>{loadSettings();byId('tgTestResult').className='';byId('tgTestResult').textContent='';byId('settingsModal').classList.add('show')});byId('settingsCancelBtn').addEventListener('click',()=>{byId('settingsModal').classList.remove('show')});byId('settingsSaveBtn').addEventListener('click',saveSettings);byId('tgTestBtn').addEventListener('click',testTelegram);byId('tgTokenReveal').addEventListener('click',()=>{const inp=byId('tgBotToken');inp.type=inp.type==='password'?'text':'password'});initTheme();loadTemplates();updateVisibility();fetchTasks()} +function init(){const modal=byId('createTaskModal');const openBtn=byId('openCreateModalBtn');const cancelBtn=byId('cancelCreateBtn');const createBtn=byId('createTaskBtn');const scheduleTypeSelect=byId('scheduleTypeSelect');const rowInterval=byId('rowInterval');const rowCron=byId('rowCron');const langSelect=byId('langSelect');lang='en';langSelect.value=lang;applyI18n();function openCreate(){clearForm();populateDependsOn(null);modal.classList.add('show')}function closeCreate(){modal.classList.remove('show');editingTaskId=null}function updateVisibility(){const t=scheduleTypeSelect.value;if(t==='cron'){rowInterval.classList.add('hidden');rowCron.classList.remove('hidden')}else{rowInterval.classList.remove('hidden');rowCron.classList.add('hidden')}}openBtn.addEventListener('click',openCreate);cancelBtn.addEventListener('click',closeCreate);createBtn.addEventListener('click',()=>{saveTask().then(ok=>{if(ok)closeCreate()})});byId('confirmCancelBtn').addEventListener('click',hideConfirm);byId('confirmOkBtn').addEventListener('click',()=>{if(confirmCallback)confirmCallback();hideConfirm()});byId('runAllBtn').addEventListener('click',runAllOnce);byId('addEnvBtn').addEventListener('click',addEnvRow);byId('cronInput').addEventListener('input',validateCronInput);byId('filterCategory').addEventListener('change',fetchTasks);byId('filterTag').addEventListener('change',fetchTasks);scheduleTypeSelect.addEventListener('change',updateVisibility);langSelect.addEventListener('change',()=>{lang=langSelect.value;applyI18n();renderTasks()});byId('themeToggle').addEventListener('click',toggleTheme);byId('templateSelect').addEventListener('change',applyTemplate);byId('settingsToggle').addEventListener('click',()=>{loadSettings();byId('tgTestResult').className='';byId('tgTestResult').textContent='';byId('settingsModal').classList.add('show')});byId('settingsCancelBtn').addEventListener('click',()=>{byId('settingsModal').classList.remove('show')});byId('settingsSaveBtn').addEventListener('click',saveSettings);byId('tgTestBtn').addEventListener('click',testTelegram);byId('tgTokenReveal').addEventListener('click',()=>{const inp=byId('tgBotToken');inp.type=inp.type==='password'?'text':'password'});initTheme();loadTemplates();updateVisibility();fetchTasks()} async function fetchTasks(){const catFilter=byId('filterCategory').value;const tagFilter=byId('filterTag').value;let url=`${API_BASE}/cron/tasks`;const params=[];if(catFilter)params.push('category='+encodeURIComponent(catFilter));if(tagFilter)params.push('tag='+encodeURIComponent(tagFilter));if(params.length)url+='?'+params.join('&');try{const res=await fetch(url);if(!res.ok)throw new Error('HTTP '+res.status);const list=await res.json();const normalize=t=>({id:t.id,name:t.name,command:t.command,type:t.type,status:t.status,executing:!!t.executing,nextRunAt:t.next_run_at||0,lastRunAt:t.last_run_at||0,lastResult:t.last_result||null,category:t.category||'',tags:t.tags||[],priority:t.priority||0,dependsOn:t.depends_on||[],allowParallel:t.allow_parallel||false,showLogs:false,logs:[]});const openLogs={};tasks.forEach(ot=>{if(ot.showLogs)openLogs[ot.id]={logs:ot.logs,logSearch:ot.logSearch,logsLoading:ot.logsLoading}});tasks=list.map(normalize);tasks.forEach(nt=>{if(openLogs[nt.id]){nt.showLogs=true;nt.logs=openLogs[nt.id].logs;nt.logSearch=openLogs[nt.id].logSearch;nt.logsLoading=openLogs[nt.id].logsLoading}});renderTasks();updateFilterOptions();if(!templates.length)loadTemplates();scheduleExecPoll()}catch(e){console.error('Failed to fetch tasks:',e);const tbody=byId('taskTableBody');tbody.innerHTML='
⏳ Waiting for backend to start...
Auto-retrying every 5 seconds
';setTimeout(fetchTasks,5000)}} -async function createTask(){const name=byId('taskNameInput').value.trim();const command=byId('commandInput').value.trim();const scheduleType=byId('scheduleTypeSelect').value;const intervalMin=parseInt(byId('intervalInput').value,10);const cronExpr=byId('cronInput').value.trim();const timeoutSec=parseInt(byId('timeoutInput').value,10)||0;const retryCount=parseInt(byId('retryCountInput').value,10)||0;const retryDelaySec=parseInt(byId('retryDelayInput').value,10)||0;const env=getEnvVars();if(!name||!command)return;if(scheduleType==='interval'){if(!intervalMin||intervalMin<1)return}else{if(!isValidCron(cronExpr))return}const category=byId('categoryInput').value.trim();const tagsRaw=byId('tagsInput').value.trim();const tags=tagsRaw?tagsRaw.split(',').map(s=>s.trim()).filter(Boolean):[];const priority=parseInt(byId('priorityInput').value,10)||0;const webhookUrl=byId('webhookUrlInput').value.trim();const onSuccess=byId('notifyOnSuccess').checked;const onFailure=byId('notifyOnFailure').checked;const payload={name,command,type:scheduleType,interval_min:intervalMin,cron_expr:cronExpr,timeout_sec:timeoutSec,retry_count:retryCount,retry_delay_sec:retryDelaySec};const dependsOn=Array.from(byId('dependsOnSelect').selectedOptions).map(o=>o.value);const allowParallel=byId('allowParallelCheck').checked;if(category)payload.category=category;if(tags.length)payload.tags=tags;if(priority)payload.priority=priority;const maxLogEntries=parseInt(byId('maxLogEntriesInput').value,10)||0;if(dependsOn.length)payload.depends_on=dependsOn;if(allowParallel)payload.allow_parallel=true;if(maxLogEntries>0)payload.max_log_entries=maxLogEntries;if(env)payload.env=env;const notifications=[];if(webhookUrl){notifications.push({enabled:true,type:'webhook',target:webhookUrl,on_success:onSuccess,on_failure:onFailure})}const emailTo=byId('emailToInput').value.trim();const smtpHost=byId('smtpHostInput').value.trim();if(emailTo&&smtpHost){notifications.push({enabled:true,type:'email',target:emailTo,on_success:byId('emailOnSuccess').checked,on_failure:byId('emailOnFailure').checked,smtp_host:smtpHost,smtp_port:parseInt(byId('smtpPortInput').value,10)||587,smtp_user:byId('smtpUserInput').value.trim(),smtp_pass:byId('smtpPassInput').value})}if(notifications.length)payload.notifications=notifications;await fetch(`${API_BASE}/cron/tasks`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});byId('taskNameInput').value='';byId('commandInput').value='';byId('intervalInput').value='';byId('cronInput').value='';byId('timeoutInput').value='';byId('retryCountInput').value='';byId('retryDelayInput').value='';byId('categoryInput').value='';byId('tagsInput').value='';byId('priorityInput').value='';byId('maxLogEntriesInput').value='';byId('dependsOnSelect').selectedIndex=-1;byId('allowParallelCheck').checked=false;byId('webhookUrlInput').value='';byId('notifyOnSuccess').checked=false;byId('notifyOnFailure').checked=true;byId('emailToInput').value='';byId('smtpHostInput').value='';byId('smtpPortInput').value='';byId('smtpUserInput').value='';byId('smtpPassInput').value='';byId('emailOnSuccess').checked=false;byId('emailOnFailure').checked=true;byId('templateSelect').value='';clearEnvRows();await fetchTasks()} +async function saveTask(){const payload=buildSavePayload();if(!payload)return false;const url=editingTaskId?`${API_BASE}/cron/tasks/${editingTaskId}`:`${API_BASE}/cron/tasks`;const method=editingTaskId?'PUT':'POST';const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});if(!res.ok)return false;await fetchTasks();return true} +async function openEdit(id){try{const res=await fetch(`${API_BASE}/cron/tasks/${id}`);if(!res.ok)return;const task=await res.json();editingTaskId=id;populateForm(task);byId('createTaskModal').classList.add('show')}catch(e){console.error('Failed to load task:',e)}} function renderTasks(){const tdict=i18n[lang];const tbody=byId('taskTableBody');tbody.innerHTML='';tasks.forEach(t=>{const tr=document.createElement('tr');const dotClass=t.executing?'executing':t.status;const statusText=t.executing?(tdict.statusExecuting||'Executing...'):t.status==='running'?tdict.statusRunning:tdict.statusPaused;const statusDot=``;const lastBadge=t.lastResult?`${t.lastResult.success?tdict.wordSuccess:tdict.wordFail}`:'-';tr.innerHTML=` ${escapeHtml(t.name)}${t.category?''+escapeHtml(t.category)+'':''}${(t.tags||[]).map(tag=>''+escapeHtml(tag)+'').join('')}${(t.dependsOn&&t.dependsOn.length)?'\u21b3 '+t.dependsOn.length+'':''} ${escapeHtml(t.command)} @@ -33,9 +40,10 @@ function renderTasks(){const tdict=i18n[lang];const tbody=byId('taskTableBody'); + `;tbody.appendChild(tr);const logsRow=document.createElement('tr');const logsTd=document.createElement('td');logsTd.colSpan=6;if(t.showLogs){const header=document.createElement('div');header.className='logs-header';header.innerHTML=`
${tdict.logsTitle}${escapeHtml(t.name)}
`;const list=document.createElement('div');list.className='logs-list';if(t.logsLoading){const item=document.createElement('div');item.className='log-item';item.innerHTML='
Loading...
';list.appendChild(item)}else{const searchTerm=(t.logSearch||'').toLowerCase();(t.logs||[]).filter(l=>!searchTerm||l.message.toLowerCase().includes(searchTerm)).slice(0,100).forEach(l=>{const item=document.createElement('div');item.className='log-item';const statusClass=l.success?'success':'fail';item.innerHTML=`
${fmtTime(l.time)}
${escapeHtml(l.message)}
${l.success?tdict.wordSuccess:tdict.wordFail}
`;list.appendChild(item)})}const chartHtml=renderExecChart(t.logs);const container=document.createElement('div');container.className='row-logs';container.appendChild(header);if(chartHtml){const chartDiv=document.createElement('div');chartDiv.innerHTML=chartHtml;container.appendChild(chartDiv)}container.appendChild(list);logsTd.appendChild(container)}logsRow.appendChild(logsTd);tbody.appendChild(logsRow)});tbody.querySelectorAll('button').forEach(btn=>btn.addEventListener('click',onRowAction));tbody.querySelectorAll('.log-search').forEach(inp=>{inp.addEventListener('input',e=>{const tid=e.target.getAttribute('data-id');const task=tasks.find(t=>t.id===tid);if(task){task.logSearch=e.target.value;renderTasks();const el=byId('taskTableBody').querySelector(`.log-search[data-id="${tid}"]`);if(el){el.focus();el.setSelectionRange(el.value.length,el.value.length)}}})})} -function onRowAction(e){const action=e.currentTarget.getAttribute('data-action');const id=e.currentTarget.getAttribute('data-id');const task=tasks.find(t=>t.id===id);if(!task)return;if(action==='run')fetch(`${API_BASE}/cron/tasks/${id}/run`,{method:'POST'}).then(fetchTasks);if(action==='toggle')fetch(`${API_BASE}/cron/tasks/${id}/toggle`,{method:'POST'}).then(fetchTasks);if(action==='logs'){task.showLogs=!task.showLogs;if(task.showLogs){task.logsLoading=true;renderTasks();fetch(`${API_BASE}/cron/tasks/${id}/logs`).then(r=>r.json()).then(d=>{task.logs=d;task.logsLoading=false;renderTasks()}).catch(()=>{task.logsLoading=false;renderTasks()})}else{renderTasks()}}if(action==='clear-logs'){fetch(`${API_BASE}/cron/tasks/${id}/logs/clear`,{method:'POST'}).then(()=>{task.logs=[];renderTasks()})}if(action==='export-csv'){window.open(`${API_BASE}/cron/tasks/${id}/logs?format=csv`)}if(action==='export-json'){window.open(`${API_BASE}/cron/tasks/${id}/logs?format=json`)}if(action==='delete'){fetch(`${API_BASE}/cron/tasks/${id}`,{method:'DELETE'}).then(fetchTasks)}} +function onRowAction(e){const action=e.currentTarget.getAttribute('data-action');const id=e.currentTarget.getAttribute('data-id');const task=tasks.find(t=>t.id===id);if(!task)return;if(action==='run')fetch(`${API_BASE}/cron/tasks/${id}/run`,{method:'POST'}).then(fetchTasks);if(action==='toggle')fetch(`${API_BASE}/cron/tasks/${id}/toggle`,{method:'POST'}).then(fetchTasks);if(action==='logs'){task.showLogs=!task.showLogs;if(task.showLogs){task.logsLoading=true;renderTasks();fetch(`${API_BASE}/cron/tasks/${id}/logs`).then(r=>r.json()).then(d=>{task.logs=d;task.logsLoading=false;renderTasks()}).catch(()=>{task.logsLoading=false;renderTasks()})}else{renderTasks()}}if(action==='clear-logs'){fetch(`${API_BASE}/cron/tasks/${id}/logs/clear`,{method:'POST'}).then(()=>{task.logs=[];renderTasks()})}if(action==='export-csv'){window.open(`${API_BASE}/cron/tasks/${id}/logs?format=csv`)}if(action==='export-json'){window.open(`${API_BASE}/cron/tasks/${id}/logs?format=json`)}if(action==='edit')openEdit(id);if(action==='delete'){const msg=(i18n[lang].deleteConfirmMsg||'Delete "{name}"?').replace('{name}',task.name);showConfirm(msg,()=>fetch(`${API_BASE}/cron/tasks/${id}`,{method:'DELETE'}).then(fetchTasks))}} function runAllOnce(){const running=tasks.filter(t=>t.status==='running');Promise.all(running.map(t=>fetch(`${API_BASE}/cron/tasks/${t.id}/run`,{method:'POST'}))).then(fetchTasks)} let execPollTimer=null;function scheduleExecPoll(){if(execPollTimer){clearTimeout(execPollTimer);execPollTimer=null}if(tasks.some(t=>t.executing)){execPollTimer=setTimeout(fetchTasks,2000)}} document.addEventListener('DOMContentLoaded',init) diff --git a/cmd/cron/main.go b/cmd/cron/main.go index cff3b32..2e66b4a 100644 --- a/cmd/cron/main.go +++ b/cmd/cron/main.go @@ -317,6 +317,89 @@ type createReq struct { MaxLogEntries int `json:"max_log_entries,omitempty"` } +const maskedSecret = "********" + +func validateNotifications(notifs []notify.Config) error { + for _, n := range notifs { + if n.Type != "webhook" && n.Type != "email" && n.Type != "telegram" { + return fmt.Errorf("invalid notification type: %s", n.Type) + } + if n.Type == "webhook" { + if err := notify.ValidateWebhookFormat(n.WebhookFormat); err != nil { + return err + } + } + } + return nil +} + +func mergeNotificationCredentials(incoming, existing []notify.Config) []notify.Config { + if len(incoming) == 0 { + return incoming + } + merged := make([]notify.Config, len(incoming)) + copy(merged, incoming) + for i := range merged { + if merged[i].SMTPPass == maskedSecret { + for _, e := range existing { + if e.Type == "email" && e.Target == merged[i].Target && e.SMTPHost == merged[i].SMTPHost { + merged[i].SMTPPass = e.SMTPPass + break + } + } + } + if merged[i].TelegramBotToken == maskedSecret { + for _, e := range existing { + if e.Type == "telegram" && e.Target == merged[i].Target { + merged[i].TelegramBotToken = e.TelegramBotToken + break + } + } + } + } + return merged +} + +func applyCreateReq(t *Task, req createReq) error { + t.Name = req.Name + t.Command = req.Command + t.Type = req.Type + t.TimeoutSec = req.TimeoutSec + t.RetryCount = req.RetryCount + t.RetryDelaySec = req.RetryDelaySec + t.Env = req.Env + t.Category = req.Category + t.Tags = req.Tags + t.Priority = req.Priority + t.DependsOn = req.DependsOn + t.AllowParallel = req.AllowParallel + t.MaxLogEntries = req.MaxLogEntries + if req.Type == "interval" { + if req.IntervalMin < 1 { + return fmt.Errorf("interval_min >=1") + } + t.Interval = time.Duration(req.IntervalMin) * time.Minute + t.CronExpr = "" + } else { + if !isValidCron(req.CronExpr) { + return fmt.Errorf("invalid cron") + } + t.CronExpr = req.CronExpr + t.Interval = 0 + } + return nil +} + +func scheduleChanged(t *Task, req createReq) bool { + if t.Type != req.Type { + return true + } + if req.Type == "interval" { + return int(t.Interval/time.Minute) != req.IntervalMin + } + return t.CronExpr != req.CronExpr +} + func tasksHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -357,12 +440,9 @@ func tasksHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid type", 400) return } - // Validate notification configs - for _, n := range req.Notifications { - if n.Type != "webhook" && n.Type != "email" && n.Type != "telegram" { - http.Error(w, "invalid notification type: "+n.Type, 400) - return - } + if err := validateNotifications(req.Notifications); err != nil { + http.Error(w, err.Error(), 400) + return } id := newTaskID() t := &Task{ @@ -375,18 +455,9 @@ func tasksHandler(w http.ResponseWriter, r *http.Request) { DependsOn: req.DependsOn, AllowParallel: req.AllowParallel, MaxLogEntries: req.MaxLogEntries, } - if req.Type == "interval" { - if req.IntervalMin < 1 { - http.Error(w, "interval_min >=1", 400) - return - } - t.Interval = time.Duration(req.IntervalMin) * time.Minute - } else { - if !isValidCron(req.CronExpr) { - http.Error(w, "invalid cron", 400) - return - } - t.CronExpr = req.CronExpr + if err := applyCreateReq(t, req); err != nil { + http.Error(w, err.Error(), 400) + return } mu.Lock() tasks[id] = t @@ -429,6 +500,46 @@ func taskActionHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) return } + if len(parts) == 1 && r.Method == http.MethodPut { + var req createReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), 400) + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Command) == "" { + http.Error(w, "name/command required", 400) + return + } + if req.Type != "interval" && req.Type != "cron" { + http.Error(w, "invalid type", 400) + return + } + if err := validateNotifications(req.Notifications); err != nil { + http.Error(w, err.Error(), 400) + return + } + mu.Lock() + existingNotifs := append([]notify.Config(nil), t.Notifications...) + needsReschedule := scheduleChanged(t, req) + wasRunning := t.Status == "running" + if err := applyCreateReq(t, req); err != nil { + mu.Unlock() + http.Error(w, err.Error(), 400) + return + } + t.Notifications = mergeNotificationCredentials(req.Notifications, existingNotifs) + if needsReschedule { + clearSchedule(t) + if wasRunning { + startSchedule(t) + } + } + mu.Unlock() + persistTask(t) + jsonResponse(w) + json.NewEncoder(w).Encode(sanitizeTask(t)) + return + } if len(parts) < 2 { w.WriteHeader(404) return @@ -1460,7 +1571,17 @@ func cronNext(expr string, from time.Time) time.Time { monthOk := monSet.set[mon] domOk := domSet.set[dom] dowOk := dowSet.set[dow] - dayOk := (domSet.isAll && dowSet.isAll) || (domSet.isAll && dowOk) || (dowSet.isAll && domOk) || (domOk || dowOk) + var dayOk bool + switch { + case domSet.isAll && dowSet.isAll: + dayOk = true + case domSet.isAll: + dayOk = dowOk + case dowSet.isAll: + dayOk = domOk + default: + dayOk = domOk || dowOk + } if minuteOk && hourOk && monthOk && dayOk { return d } diff --git a/index.html b/index.html index b7675aa..3f32734 100644 --- a/index.html +++ b/index.html @@ -57,9 +57,9 @@

Task List