-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathapp.js
More file actions
41 lines (41 loc) · 24.1 KB
/
Copy pathapp.js
File metadata and controls
41 lines (41 loc) · 24.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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}
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='<input type="text" placeholder="KEY" class="env-key"><input type="text" placeholder="VALUE" class="env-val"><button type="button" class="env-remove">×</button>';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=''}
// --- 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()}
function updateThemeIcon(){const btn=byId('themeToggle');if(!btn)return;const isLight=document.documentElement.getAttribute('data-theme')==='light';btn.innerHTML=isLight?'☀':'☾'}
// --- Templates ---
let templates=[];
function loadTemplates(){fetch(`${API_BASE}/cron/templates`).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json()}).then(d=>{templates=d||[];const sel=byId('templateSelect');if(!sel)return;sel.innerHTML='<option value="">-- Blank Task --</option>';templates.forEach(t=>{const o=document.createElement('option');o.value=t.id;o.textContent=t.name+' — '+t.description;sel.appendChild(o)})}).catch(e=>{console.error('Failed to load templates:',e);setTimeout(loadTemplates,5000)})}
function applyTemplate(){const sel=byId('templateSelect');if(!sel||!sel.value)return;const t=templates.find(x=>x.id===sel.value);if(!t)return;byId('taskNameInput').value=t.name;byId('commandInput').value=t.command;byId('categoryInput').value=t.category||'';if(t.timeout_sec)byId('timeoutInput').value=t.timeout_sec;const st=byId('scheduleTypeSelect');st.value=t.type;st.dispatchEvent(new Event('change'));if(t.type==='interval'&&t.interval_min)byId('intervalInput').value=t.interval_min;if(t.type==='cron'&&t.cron_expr){byId('cronInput').value=t.cron_expr;validateCronInput()}}
// --- Settings ---
function loadSettings(){fetch(`${API_BASE}/cron/settings`).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json()}).then(d=>{byId('tgBotToken').value=d.telegram_bot_token||'';byId('tgChatId').value=d.telegram_chat_id||'';byId('tgNotifySuccess').checked=!!d.telegram_on_success;byId('tgNotifyFailure').checked=d.telegram_on_failure!==false}).catch(e=>{console.error('Failed to load settings:',e)})}
function saveSettings(){const token=byId('tgBotToken').value.trim();const chatId=byId('tgChatId').value.trim();const onSuccess=byId('tgNotifySuccess').checked;const onFailure=byId('tgNotifyFailure').checked;fetch(`${API_BASE}/cron/settings`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({telegram_bot_token:token,telegram_chat_id:chatId,telegram_on_success:onSuccess,telegram_on_failure:onFailure})}).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json()}).then(()=>{byId('settingsModal').classList.remove('show')}).catch(e=>{alert('Failed to save: '+e.message)})}
function testTelegram(){const token=byId('tgBotToken').value.trim();const chatId=byId('tgChatId').value.trim();const el=byId('tgTestResult');const tdict=i18n[lang];el.className='test-result';el.textContent='Sending...';fetch(`${API_BASE}/cron/settings/test-telegram`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({bot_token:token,chat_id:chatId})}).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json()}).then(d=>{if(d.success){el.className='test-result ok';el.textContent='\u2705 '+(tdict.tgTestOk||'Message sent!')}else{el.className='test-result err';el.textContent='\u274c '+(tdict.tgTestFail||'Failed')+': '+d.error}}).catch(e=>{el.className='test-result err';el.textContent='\u274c '+e.message})}
// --- Execution History Chart ---
function renderExecChart(logs){if(!logs||logs.length<2)return '';const maxBars=30;const recent=logs.slice(0,maxBars).reverse();const w=recent.length*10;const h=32;let bars='';recent.forEach((l,i)=>{const color=l.success?'var(--success)':'var(--danger)';bars+=`<rect x="${i*10}" y="0" width="8" height="${h}" rx="2" fill="${color}" opacity="0.7"><title>${fmtTime(l.time)} — ${l.success?'OK':'FAIL'} (${l.duration_ms}ms)</title></rect>`});return `<div class="exec-chart"><svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${bars}</svg></div>`}
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='<span>✓ '+(tdict.cronValid||'Valid')+'</span>';if(d.next_runs&&d.next_runs.length){html+='<div class="next-runs">'+(tdict.nextRuns||'Next:')+' '+d.next_runs.map(ts=>fmtTime(ts)).join(', ')+'</div>'}fb.className='cron-feedback valid';fb.innerHTML=html}else{const msgs=d.errors.map(e=>'<div>'+escapeHtml(e.field)+': '+escapeHtml(e.message)+'</div>').join('');fb.className='cron-feedback invalid';fb.innerHTML='<span>✗ '+(tdict.cronInvalid||'Invalid')+'</span>'+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='<option value="">'+(tdict.filterAll||'All Categories')+'</option>';(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='<option value="">'+(tdict.filterAllTags||'All Tags')+'</option>';(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()}
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='<tr><td colspan="6" style="text-align:center;padding:2rem;color:var(--muted,#888)"><div style="margin-bottom:0.5rem">⏳ Waiting for backend to start...</div><div style="font-size:0.85em;opacity:0.7">Auto-retrying every 5 seconds</div></td></tr>';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()}
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=`<span class="dot ${dotClass}"></span>`;const lastBadge=t.lastResult?`<span class="result-badge"><span class="dot ${t.lastResult.success?'success':'fail'}"></span>${t.lastResult.success?tdict.wordSuccess:tdict.wordFail}</span>`:'<span class="muted">-</span>';tr.innerHTML=`
<td>${escapeHtml(t.name)}${t.category?'<span class="category-badge">'+escapeHtml(t.category)+'</span>':''}${(t.tags||[]).map(tag=>'<span class="tag-badge">'+escapeHtml(tag)+'</span>').join('')}${(t.dependsOn&&t.dependsOn.length)?'<span class="tag-badge" title="depends on '+t.dependsOn.length+' task(s)">\u21b3 '+t.dependsOn.length+'</span>':''}</td>
<td><code>${escapeHtml(t.command)}</code></td>
<td><span class="status">${statusDot}${statusText}</span></td>
<td>${fmtTime(t.nextRunAt)}</td>
<td>${lastBadge}</td>
<td class="actions">
<button data-action="run" data-id="${t.id}">${tdict.btnRun}</button>
<button data-action="toggle" data-id="${t.id}">${t.status==='running'?tdict.btnPause:tdict.btnResume}</button>
<button data-action="logs" data-id="${t.id}">${t.showLogs?tdict.btnLogsHide:tdict.btnLogsShow}</button>
<button data-action="delete" data-id="${t.id}">${tdict.btnDelete}</button>
</td>`;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=`<div class="muted">${tdict.logsTitle}${escapeHtml(t.name)}</div><div class="list-actions"><input type="text" class="log-search" data-id="${t.id}" placeholder="${tdict.logSearch||'Search...'}" value="${t.logSearch||''}"><button data-action="export-csv" data-id="${t.id}">${tdict.exportCsv||'CSV'}</button><button data-action="export-json" data-id="${t.id}">${tdict.exportJson||'JSON'}</button><button data-action="clear-logs" data-id="${t.id}">${tdict.btnClearLogs}</button></div>`;const list=document.createElement('div');list.className='logs-list';if(t.logsLoading){const item=document.createElement('div');item.className='log-item';item.innerHTML='<div class="muted">Loading...</div>';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=`<div class="log-time">${fmtTime(l.time)}</div><div>${escapeHtml(l.message)}</div><div class="log-status ${statusClass}">${l.success?tdict.wordSuccess:tdict.wordFail}</div>`;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 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)