Settings
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
index 5847a28..51e9dc5 100644
--- a/internal/notify/notify.go
+++ b/internal/notify/notify.go
@@ -32,6 +32,9 @@ type Config struct {
// Telegram settings (only for type "telegram")
TelegramBotToken string `json:"telegram_bot_token,omitempty"`
+
+ // Webhook format (only for type "webhook")
+ WebhookFormat string `json:"webhook_format,omitempty"` // generic, n8n, discord, slack, home_assistant, uptime_kuma
}
// TaskInfo is a minimal view of a task for notification payloads.
@@ -48,7 +51,7 @@ type ResultInfo struct {
DurationMs int64 `json:"duration_ms"`
}
-// webhookPayload is the JSON body sent to webhook targets.
+// webhookPayload is the JSON body sent to generic webhook targets.
type webhookPayload struct {
Event string `json:"event"`
Task TaskInfo `json:"task"`
@@ -56,6 +59,45 @@ type webhookPayload struct {
Timestamp int64 `json:"timestamp"`
}
+// n8nPayload is a flat JSON body for n8n webhook nodes.
+type n8nPayload struct {
+ Event string `json:"event"`
+ TaskID string `json:"task_id"`
+ TaskName string `json:"task_name"`
+ Command string `json:"command"`
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ DurationMs int64 `json:"duration_ms"`
+ Timestamp int64 `json:"timestamp"`
+}
+
+// homeAssistantPayload is the JSON body for Home Assistant webhooks.
+type homeAssistantPayload struct {
+ Message string `json:"message"`
+ TaskName string `json:"task_name"`
+ Success bool `json:"success"`
+ DurationMs int64 `json:"duration_ms"`
+ Output string `json:"output"`
+ Timestamp int64 `json:"timestamp"`
+}
+
+// ValidWebhookFormats lists supported webhook_format values.
+var ValidWebhookFormats = map[string]bool{
+ "generic": true, "n8n": true, "discord": true,
+ "slack": true, "home_assistant": true, "uptime_kuma": true,
+}
+
+// ValidateWebhookFormat returns an error if format is non-empty and unknown.
+func ValidateWebhookFormat(format string) error {
+ if format == "" {
+ return nil
+ }
+ if !ValidWebhookFormats[format] {
+ return fmt.Errorf("invalid webhook_format: %s", format)
+ }
+ return nil
+}
+
// Send dispatches notifications for all matching configs.
// It runs asynchronously and logs errors rather than returning them.
func Send(configs []Config, task TaskInfo, result ResultInfo) {
@@ -80,7 +122,7 @@ func Send(configs []Config, task TaskInfo, result ResultInfo) {
func dispatch(cfg Config, task TaskInfo, result ResultInfo) error {
switch cfg.Type {
case "webhook":
- return sendWebhook(cfg.Target, task, result)
+ return sendWebhook(cfg, task, result)
case "email":
return sendEmail(cfg, task, result)
case "telegram":
@@ -129,21 +171,115 @@ func validateWebhookURL(rawURL string) error {
return nil
}
-func sendWebhook(webhookURL string, task TaskInfo, result ResultInfo) error {
+func webhookFormat(cfg Config) string {
+ if cfg.WebhookFormat == "" {
+ return "generic"
+ }
+ return cfg.WebhookFormat
+}
+
+func buildStatusMessage(task TaskInfo, result ResultInfo) string {
+ status := "FAILED"
+ emoji := "\u274c"
+ if result.Success {
+ status = "SUCCESS"
+ emoji = "\u2705"
+ }
+ msg := result.Message
+ if len(msg) > 500 {
+ msg = msg[:500] + "..."
+ }
+ return fmt.Sprintf("%s %s — %s (%dms)\n```%s```", emoji, status, task.Name, result.DurationMs, msg)
+}
+
+func sendWebhook(cfg Config, task TaskInfo, result ResultInfo) error {
+ webhookURL := cfg.Target
if err := validateWebhookURL(webhookURL); err != nil {
return fmt.Errorf("webhook validation: %w", err)
}
- payload := webhookPayload{
- Event: "task_completed",
- Task: task,
- Result: result,
- Timestamp: time.Now().Unix(),
+
+ format := webhookFormat(cfg)
+ ts := time.Now().Unix()
+
+ switch format {
+ case "uptime_kuma":
+ return sendUptimeKumaWebhook(webhookURL, task, result)
+ case "n8n":
+ body, err := json.Marshal(n8nPayload{
+ Event: "task_completed", TaskID: task.ID, TaskName: task.Name,
+ Command: task.Command, Success: result.Success, Message: result.Message,
+ DurationMs: result.DurationMs, Timestamp: ts,
+ })
+ if err != nil {
+ return fmt.Errorf("marshal payload: %w", err)
+ }
+ return postWebhook(webhookURL, "application/json", body)
+ case "discord":
+ body, err := json.Marshal(map[string]string{"content": buildStatusMessage(task, result)})
+ if err != nil {
+ return fmt.Errorf("marshal payload: %w", err)
+ }
+ return postWebhook(webhookURL, "application/json", body)
+ case "slack":
+ body, err := json.Marshal(map[string]string{"text": buildStatusMessage(task, result)})
+ if err != nil {
+ return fmt.Errorf("marshal payload: %w", err)
+ }
+ return postWebhook(webhookURL, "application/json", body)
+ case "home_assistant":
+ status := "failed"
+ if result.Success {
+ status = "succeeded"
+ }
+ body, err := json.Marshal(homeAssistantPayload{
+ Message: fmt.Sprintf("Task %s %s", task.Name, status),
+ TaskName: task.Name, Success: result.Success,
+ DurationMs: result.DurationMs, Output: result.Message, Timestamp: ts,
+ })
+ if err != nil {
+ return fmt.Errorf("marshal payload: %w", err)
+ }
+ return postWebhook(webhookURL, "application/json", body)
+ default: // generic
+ body, err := json.Marshal(webhookPayload{
+ Event: "task_completed", Task: task, Result: result, Timestamp: ts,
+ })
+ if err != nil {
+ return fmt.Errorf("marshal payload: %w", err)
+ }
+ return postWebhook(webhookURL, "application/json", body)
+ }
+}
+
+func sendUptimeKumaWebhook(webhookURL string, task TaskInfo, result ResultInfo) error {
+ status := "down"
+ if result.Success {
+ status = "up"
}
- body, err := json.Marshal(payload)
+ msg := fmt.Sprintf("%s: %s", task.Name, result.Message)
+ if len(msg) > 200 {
+ msg = msg[:200]
+ }
+ u, err := url.Parse(webhookURL)
if err != nil {
- return fmt.Errorf("marshal payload: %w", err)
+ return fmt.Errorf("parse URL: %w", err)
+ }
+ q := u.Query()
+ q.Set("status", status)
+ q.Set("msg", msg)
+ q.Set("ping", strconv.FormatInt(result.DurationMs, 10))
+ u.RawQuery = q.Encode()
+ return postWebhook(u.String(), "application/json", nil)
+}
+
+func postWebhook(webhookURL, contentType string, body []byte) error {
+ var resp *http.Response
+ var err error
+ if body == nil {
+ resp, err = httpClient.Post(webhookURL, contentType, http.NoBody)
+ } else {
+ resp, err = httpClient.Post(webhookURL, contentType, bytes.NewReader(body))
}
- resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("POST %s: %w", webhookURL, err)
}
diff --git a/raw/usr/share/casaos/www/modules/cron/app.js b/raw/usr/share/casaos/www/modules/cron/app.js
index 945002b..dc332af 100644
--- a/raw/usr/share/casaos/www/modules/cron/app.js
+++ b/raw/usr/share/casaos/www/modules/cron/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/raw/usr/share/casaos/www/modules/cron/index.html b/raw/usr/share/casaos/www/modules/cron/index.html
index b7675aa..3f32734 100644
--- a/raw/usr/share/casaos/www/modules/cron/index.html
+++ b/raw/usr/share/casaos/www/modules/cron/index.html
@@ -57,9 +57,9 @@
Task List
-
Create Task
+
Create Task
-
+
+
+
Delete Task?
+
+
+
+
+
+
+
Settings
diff --git a/raw/usr/share/casaos/www/modules/cron/styles.css b/raw/usr/share/casaos/www/modules/cron/styles.css
index 89ed0d8..07e2579 100644
--- a/raw/usr/share/casaos/www/modules/cron/styles.css
+++ b/raw/usr/share/casaos/www/modules/cron/styles.css
@@ -13,7 +13,8 @@ button.primary{background:var(--primary);border-color:transparent;color:#fff}but
.tasks-table{width:100%;border-collapse:collapse}.tasks-table th,.tasks-table td{border-bottom:1px solid var(--border);padding:10px;font-size:13px;vertical-align:top}.tasks-table th{color:var(--muted);font-weight:500;text-align:left}.tasks-table tr:hover td{background:var(--input-bg)}
.status{display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:999px;border:1px solid var(--border)}.dot{width:8px;height:8px;border-radius:999px}.dot.running{background:var(--success)}.dot.paused{background:var(--warning)}.dot.executing{background:var(--primary);animation:pulse 1s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 var(--primary)}50%{opacity:.6;box-shadow:0 0 8px 4px var(--primary)}}.result-badge{display:inline-flex;align-items:center;gap:6px}.dot.success{background:var(--success)}.dot.fail{background:var(--danger)}
.actions{display:flex;gap:8px}
-.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:1000}.modal-overlay.show{display:flex}.modal{background:var(--panel);border:1px solid var(--border);border-radius:12px;width:520px;max-width:90vw;max-height:85vh;display:flex;flex-direction:column}.modal h3{padding:16px 16px 0;margin:0 0 12px}.modal-body{flex:1;overflow-y:auto;padding:0 16px;min-height:0}.modal-actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--border)}
+.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:1000}.modal-overlay.show{display:flex}.modal{background:var(--panel);border:1px solid var(--border);border-radius:12px;width:520px;max-width:90vw;max-height:85vh;display:flex;flex-direction:column}.modal.modal-sm{width:400px}.modal h3{padding:16px 16px 0;margin:0 0 12px}.modal-body{flex:1;overflow-y:auto;padding:0 16px;min-height:0}.modal-actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--border)}
+button.danger{background:var(--danger);border-color:transparent;color:#fff}
.row-logs{background:var(--input-bg);border-top:1px dashed var(--border);padding:12px}.logs-list{display:grid;gap:8px;max-height:240px;overflow:auto}.log-item{display:grid;grid-template-columns:140px 1fr 80px;gap:12px;align-items:center;padding:8px 10px;border:1px solid var(--border);border-radius:8px}.log-time{color:var(--muted);font-size:12px}.log-status.success{color:var(--success)}.log-status.fail{color:var(--danger)}
.muted{color:var(--muted)}
.advanced-toggle{margin-bottom:12px;border:1px solid var(--border);border-radius:8px;padding:8px 12px}
diff --git a/styles.css b/styles.css
index 89ed0d8..07e2579 100644
--- a/styles.css
+++ b/styles.css
@@ -13,7 +13,8 @@ button.primary{background:var(--primary);border-color:transparent;color:#fff}but
.tasks-table{width:100%;border-collapse:collapse}.tasks-table th,.tasks-table td{border-bottom:1px solid var(--border);padding:10px;font-size:13px;vertical-align:top}.tasks-table th{color:var(--muted);font-weight:500;text-align:left}.tasks-table tr:hover td{background:var(--input-bg)}
.status{display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:999px;border:1px solid var(--border)}.dot{width:8px;height:8px;border-radius:999px}.dot.running{background:var(--success)}.dot.paused{background:var(--warning)}.dot.executing{background:var(--primary);animation:pulse 1s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 var(--primary)}50%{opacity:.6;box-shadow:0 0 8px 4px var(--primary)}}.result-badge{display:inline-flex;align-items:center;gap:6px}.dot.success{background:var(--success)}.dot.fail{background:var(--danger)}
.actions{display:flex;gap:8px}
-.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:1000}.modal-overlay.show{display:flex}.modal{background:var(--panel);border:1px solid var(--border);border-radius:12px;width:520px;max-width:90vw;max-height:85vh;display:flex;flex-direction:column}.modal h3{padding:16px 16px 0;margin:0 0 12px}.modal-body{flex:1;overflow-y:auto;padding:0 16px;min-height:0}.modal-actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--border)}
+.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:1000}.modal-overlay.show{display:flex}.modal{background:var(--panel);border:1px solid var(--border);border-radius:12px;width:520px;max-width:90vw;max-height:85vh;display:flex;flex-direction:column}.modal.modal-sm{width:400px}.modal h3{padding:16px 16px 0;margin:0 0 12px}.modal-body{flex:1;overflow-y:auto;padding:0 16px;min-height:0}.modal-actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--border)}
+button.danger{background:var(--danger);border-color:transparent;color:#fff}
.row-logs{background:var(--input-bg);border-top:1px dashed var(--border);padding:12px}.logs-list{display:grid;gap:8px;max-height:240px;overflow:auto}.log-item{display:grid;grid-template-columns:140px 1fr 80px;gap:12px;align-items:center;padding:8px 10px;border:1px solid var(--border);border-radius:8px}.log-time{color:var(--muted);font-size:12px}.log-status.success{color:var(--success)}.log-status.fail{color:var(--danger)}
.muted{color:var(--muted)}
.advanced-toggle{margin-bottom:12px;border:1px solid var(--border);border-radius:8px;padding:8px 12px}