177 lines
20 KiB
JavaScript
177 lines
20 KiB
JavaScript
/* Authenticated task management. Credentials are never saved in localStorage. */
|
||
(()=>{
|
||
'use strict';
|
||
let auth={logged_in:false},tasks=[],listBusy=false,listRefreshPending=false,selectedRun=null,runsTask=null,confirmResolve=null,runReadSequence=0;
|
||
const states={running:['执行中','running'],success:['执行成功','success'],failed:['执行失败','failed'],timeout:['超时已停止','failed'],cancelled:['已停止','neutral'],interrupted:['执行被中断','warning']};
|
||
function taskError(id,message){$(id).textContent=message;$(id).hidden=!message;}
|
||
async function request(path,body){
|
||
const options={cache:'no-store',signal:AbortSignal.timeout(28000),credentials:'same-origin'};
|
||
if(body!==undefined){options.method='POST';options.headers={'Content-Type':'application/json','X-Task-CSRF':auth.csrf||''};options.body=JSON.stringify(body);}
|
||
const response=await fetch('/api/tasks'+path,options);
|
||
const data=await response.json();
|
||
if(!response.ok){
|
||
if(response.status===401&&path!=='/login'){setAuth({logged_in:false});}
|
||
const error=new Error(data.error||'任务操作未完成。');error.status=response.status;throw error;
|
||
}
|
||
return data;
|
||
}
|
||
function setAuth(value){
|
||
auth=value;$('task-manager').hidden=!value.logged_in;$('task-locked').hidden=!!value.logged_in;
|
||
$('task-create').disabled=!value.logged_in;$('task-login-open').hidden=!!value.logged_in;
|
||
$('task-change-password').hidden=!value.logged_in;$('task-logout').hidden=!value.logged_in;
|
||
$('task-auth-title').textContent=value.logged_in?'管理员已登录':'定时任务管理未登录';
|
||
$('task-auth-description').textContent=value.logged_in?'可新增、编辑和执行任务。登录最长有效 8 小时;执行身份为 root。':'设备监控无需登录;创建、修改或执行服务器任务需要管理员登录。';
|
||
if(!value.logged_in){tasks=[];$('task-list').replaceChildren();}
|
||
queueNavigation();
|
||
}
|
||
async function refreshAuth(){
|
||
try{const value=await request('/auth');setAuth(value);if(value.logged_in)await refreshList();}
|
||
catch(error){$('task-auth-title').textContent='任务管理服务暂不可用';$('task-auth-description').textContent=error.message;$('task-create').disabled=true;}
|
||
}
|
||
function openLogin(){taskError('task-login-error','');$('task-login-password').value='';$('task-login-dialog').showModal();$('task-login-password').focus();}
|
||
$('task-login-open').addEventListener('click',openLogin);
|
||
$('task-login-form').addEventListener('submit',async event=>{
|
||
event.preventDefault();const button=event.submitter;button.disabled=true;taskError('task-login-error','');
|
||
try{const result=await request('/login',{username:$('task-login-user').value.trim(),password:$('task-login-password').value});setAuth(result);$('task-login-password').value='';$('task-login-dialog').close();await refreshList();toast('已登录定时任务管理。');}
|
||
catch(error){taskError('task-login-error',error.message);}finally{button.disabled=false;}
|
||
});
|
||
$('task-logout').addEventListener('click',async()=>{try{await request('/logout',{});setAuth({logged_in:false});document.querySelectorAll('.task-dialog[open]').forEach(dialog=>dialog.close());$('task-body').value='';$('task-run-output').textContent='';toast('已退出任务管理。');}catch(error){toast(error.message);}});
|
||
document.querySelectorAll('[data-close-dialog]').forEach(button=>button.addEventListener('click',()=>$(button.dataset.closeDialog).close()));
|
||
function confirmAction(title,message,code=''){
|
||
return new Promise(resolve=>{confirmResolve=resolve;$('task-confirm-heading').textContent=title;$('task-confirm-message').textContent=message;$('task-confirm-code').textContent=code;$('task-confirm-code').hidden=!code;$('task-confirm-dialog').showModal();});
|
||
}
|
||
function finishConfirm(value){$('task-confirm-dialog').close();const resolve=confirmResolve;confirmResolve=null;resolve?.(value);}
|
||
$('task-confirm-no').addEventListener('click',()=>finishConfirm(false));$('task-confirm-yes').addEventListener('click',()=>finishConfirm(true));$('task-confirm-dialog').addEventListener('cancel',event=>{event.preventDefault();finishConfirm(false);});
|
||
function stateBadge(state){const [text,cls]=states[state]||['尚未执行','neutral'];return el('span','task-state '+cls,text);}
|
||
function actionButton(label,action,task,cls=''){
|
||
const button=el('button','task-action '+cls,label);button.type='button';button.addEventListener('click',()=>taskAction(action,task,button));return button;
|
||
}
|
||
function renderList(data){
|
||
tasks=data.tasks||[];const holder=$('task-list');holder.replaceChildren();
|
||
$('task-count').textContent=`共 ${tasks.length} 个任务 · ${tasks.filter(x=>x.enabled).length} 个已启用 · ${tasks.filter(x=>x.running).length} 个执行中`;
|
||
$('task-server-time').textContent='服务器时间 '+when(data.server_time);
|
||
if(!tasks.length){const empty=el('div','task-empty');empty.append(icon('clock'),el('h3','','还没有计划任务'),el('p','','点击“新增任务”,选择模板或填写自己的命令。任务默认停用,可先测试再启用。'));holder.append(empty);return;}
|
||
tasks.forEach(task=>{
|
||
const row=el('article','task-row');row.dataset.taskId=task.id;
|
||
const name=el('div','task-name-cell');name.append(el('strong','',task.name));const meta=el('div','task-row-meta');meta.append(el('span','',({command:'命令',script:'脚本内容',file:'服务器脚本'})[task.mode]||task.mode),el('span','','root'));if(task.dangerous)meta.append(el('span','task-danger-label','重启/关机类'));name.append(meta);if(task.notes)name.append(el('p','',task.notes));
|
||
const schedule=el('div','task-schedule-cell');schedule.append(el('strong','',task.schedule_label),el('span','',task.enabled?(task.next_run?'下次:'+when(task.next_run):'没有下次执行时间/单次任务已到期'):'计划已停用,不会自动执行'));
|
||
const status=el('div','task-status-cell');status.append(el('span','task-enabled '+(task.enabled?'enabled':''),task.enabled?'计划已启用':'计划已停用'),stateBadge(task.running?'running':task.last_run?.state));if(task.last_run)status.append(el('small','','最近:'+when(task.last_run.started)));
|
||
const actions=el('div','task-row-actions');
|
||
if(task.running)actions.append(actionButton('停止本次','stop',task,'danger'));
|
||
else actions.append(actionButton('执行一次','run',task));
|
||
actions.append(actionButton('日志','logs',task),actionButton('编辑','edit',task),actionButton(task.enabled?'停用':'启用','enable',task));
|
||
if(!task.running)actions.append(actionButton('删除','delete',task,'danger'));
|
||
row.append(name,schedule,status,actions);holder.append(row);
|
||
});queueNavigation();
|
||
}
|
||
async function refreshList(showMessage=false){if(!auth.logged_in)return;if(listBusy){listRefreshPending=true;return;}listBusy=true;try{renderList(await request(''));if(showMessage)toast('任务状态已更新。');}catch(error){if(error.status!==401)toast(error.message);}finally{listBusy=false;if(listRefreshPending){listRefreshPending=false;refreshList();}}}
|
||
$('task-refresh').addEventListener('click',()=>refreshList(true));
|
||
async function taskAction(action,task,button){
|
||
button.disabled=true;
|
||
try{
|
||
if(action==='edit'){await openEditor(task.id);return;}
|
||
if(action==='logs'){await openRuns(task);return;}
|
||
if(action==='run'){
|
||
const detail=await request('/item?id='+encodeURIComponent(task.id));
|
||
const code=detail.definition.mode==='file'?detail.definition.path+' '+detail.definition.arguments:detail.definition.body;
|
||
if(!await confirmAction('立即执行“'+task.name+'”?',task.dangerous?'这可能会立刻重启或关闭服务器,中断计算任务和网页连接。只有确认现在可以中断业务时才继续。':'会立刻以 root 身份执行以下内容。手动执行不会自动启用这个任务的计划。',code.slice(0,1200)))return;
|
||
const result=await request('/run',{id:task.id});toast(result.message);await refreshList();await openRuns(task);return;
|
||
}
|
||
if(action==='enable'){
|
||
if(!await confirmAction(task.enabled?'停用这项计划?':'启用这项计划?',task.enabled?'只停止以后的定时触发,已经开始的执行不会被强制中断。':`执行周期:${task.schedule_label}。${task.dangerous?'此任务可能重启/关闭服务器,请确认执行时段允许中断业务。':'到计划时间会以 root 身份执行。'}`))return;
|
||
toast((await request('/enable',{id:task.id,enabled:!task.enabled})).message);
|
||
}
|
||
if(action==='delete'){
|
||
if(!await confirmAction('删除“'+task.name+'”?','将停止后续调度并删除任务定义,已有执行记录仍保留。'))return;
|
||
toast((await request('/delete',{id:task.id})).message);
|
||
}
|
||
if(action==='stop'){
|
||
if(!await confirmAction('停止本次执行?','将终止本任务的执行进程。脚本可能尚未完成修改,请先确认可以中断。'))return;
|
||
toast((await request('/stop',{id:task.id})).message);
|
||
}
|
||
await refreshList();
|
||
}catch(error){toast(error.message);}finally{button.disabled=false;}
|
||
}
|
||
function localDateValue(timestamp){const parts=new Intl.DateTimeFormat('sv-SE',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).format(new Date(timestamp*1000));return parts.replace(' ','T');}
|
||
function updateEditorFields(){
|
||
const mode=$('task-mode').value,cycle=$('task-cycle').value;
|
||
$('task-body-field').hidden=mode==='file';$('task-body').required=mode!=='file';$('task-body').disabled=mode==='file';
|
||
$('task-path-field').hidden=mode!=='file';$('task-path').required=mode==='file';$('task-path').disabled=mode!=='file';$('task-arguments-field').hidden=mode!=='file';
|
||
$('task-language-field').hidden=mode==='command';$('task-body-label').textContent=mode==='command'?'执行命令':'脚本内容';$('task-body').rows=mode==='command'?5:10;
|
||
for(const option of $('task-interpreter').options)option.disabled=option.value==='auto'&&mode!=='file';
|
||
if(mode==='script'&&$('task-interpreter').value==='auto')$('task-interpreter').value='bash';
|
||
document.querySelectorAll('[data-task-cycle]').forEach(wrapper=>{const shown=wrapper.dataset.taskCycle.split(' ').includes(cycle);wrapper.hidden=!shown;wrapper.querySelectorAll('input,select').forEach(input=>input.disabled=!shown);});
|
||
$('task-every').max=cycle==='hours'?'168':'1440';
|
||
$('task-save').textContent=$('task-enabled').checked?'保存并启用':'保存任务';
|
||
$('task-preview-text').textContent='修改后可检查执行周期;错过时间不补跑。';
|
||
}
|
||
function formDefinition(){
|
||
const kind=$('task-cycle').value;const schedule={kind};
|
||
if(['daily','weekly','monthly'].includes(kind))schedule.time=$('task-time').value;
|
||
if(kind==='weekly')schedule.weekdays=[...$('task-weekdays').querySelectorAll('input:checked')].map(x=>Number(x.value));
|
||
if(kind==='monthly')schedule.day=Number($('task-day').value);
|
||
if(kind==='minutes'||kind==='hours')schedule.every=Number($('task-every').value);
|
||
if(kind==='once')schedule.at=$('task-once').value;
|
||
if(kind==='cron')schedule.expression=$('task-cron').value;
|
||
return {id:$('task-id').value||undefined,name:$('task-name').value.trim(),template:$('task-template').value,
|
||
mode:$('task-mode').value,interpreter:$('task-interpreter').value,body:$('task-body').value,
|
||
path:$('task-path').value,arguments:$('task-arguments').value,cwd:$('task-cwd').value,
|
||
timeout:Number($('task-timeout').value),notes:$('task-notes').value,enabled:$('task-enabled').checked,schedule};
|
||
}
|
||
async function openEditor(id=null){
|
||
$('task-editor-form').reset();$('task-id').value='';$('task-template-hint').hidden=true;taskError('task-editor-error','');
|
||
$('task-once').value=localDateValue(Date.now()/1000+3600);$('task-enabled').checked=false;
|
||
$('task-editor-heading').textContent=id?'编辑定时任务':'新增定时任务';
|
||
if(id){const task=await request('/item?id='+encodeURIComponent(id)),d=task.definition,s=d.schedule;
|
||
$('task-id').value=id;$('task-name').value=d.name;$('task-template').value=d.template||'custom';$('task-mode').value=d.mode;
|
||
$('task-interpreter').value=d.interpreter;$('task-body').value=d.body;$('task-path').value=d.path;$('task-arguments').value=d.arguments;
|
||
$('task-cwd').value=d.cwd;$('task-timeout').value=d.timeout;$('task-notes').value=d.notes;$('task-enabled').checked=!!task.enabled;
|
||
$('task-cycle').value=s.kind;$('task-time').value=s.time||'03:00';$('task-day').value=s.day||1;$('task-every').value=s.every||60;$('task-cron').value=s.expression||'0 3 * * *';if(s.at)$('task-once').value=s.at.slice(0,16);
|
||
$('task-weekdays').querySelectorAll('input').forEach(input=>input.checked=(s.weekdays||[0]).includes(Number(input.value)));
|
||
}
|
||
updateEditorFields();$('task-editor-dialog').showModal();$('task-name').focus();
|
||
}
|
||
$('task-create').addEventListener('click',()=>openEditor().catch(error=>toast(error.message)));
|
||
['task-mode','task-cycle','task-enabled'].forEach(id=>$(id).addEventListener('change',updateEditorFields));
|
||
$('task-preview').addEventListener('click',async()=>{if(!$('task-editor-form').reportValidity())return;const button=$('task-preview');button.disabled=true;try{const result=await request('/preview',formDefinition());$('task-preview-text').textContent=result.schedule_label+';预计下次:'+(result.next_run?when(result.next_run):'没有可计算的将来时间');}catch(error){$('task-preview-text').textContent=error.message;}finally{button.disabled=false;}});
|
||
$('task-editor-form').addEventListener('submit',async event=>{
|
||
event.preventDefault();taskError('task-editor-error','');const definition=formDefinition();
|
||
const dangerous=/\b(reboot|shutdown|poweroff|halt)\b|\binit\s+[06]\b/i.test(definition.body+' '+definition.path);
|
||
if(definition.enabled&&!await confirmAction(dangerous?'保存并启用重启/关机类任务?':'保存并启用任务?',dangerous?'此任务将在计划时间中断服务器业务。请确认脚本和执行时段已核对。':'保存后会按设置的周期执行,不会立即运行。',definition.mode==='file'?definition.path:definition.body.slice(0,800)))return;
|
||
$('task-save').disabled=true;
|
||
try{const result=await request('/save',definition);$('task-editor-dialog').close();toast(result.message);await refreshList();}
|
||
catch(error){taskError('task-editor-error',error.message);}finally{$('task-save').disabled=false;}
|
||
});
|
||
$('task-template').addEventListener('change',()=>{
|
||
const type=$('task-template').value;$('task-enabled').checked=false;$('task-template-hint').hidden=type==='custom';
|
||
if(type==='hardware'){$('task-name').value='检查显卡状态';$('task-mode').value='command';$('task-body').value='/usr/bin/nvidia-smi';$('task-cycle').value='daily';$('task-time').value='08:00';$('task-template-hint').textContent='只读取显卡状态,结果会出现在执行日志。可先保存为停用,再执行一次测试。';}
|
||
if(type==='reboot'){$('task-name').value='服务器定时重启';$('task-mode').value='command';$('task-body').value='/usr/bin/systemctl reboot';$('task-cycle').value='weekly';$('task-time').value='03:00';$('task-weekdays').querySelectorAll('input').forEach(input=>input.checked=input.value==='6');$('task-template-hint').textContent='这条命令会重启整台服务器,正在运行的 GPU 任务会中断。模板默认停用,请先确认允许重启的时间。';}
|
||
if(type==='certificate'){$('task-name').value='SSL 证书续签';$('task-mode').value='script';$('task-interpreter').value='bash';$('task-cycle').value='daily';$('task-time').value='03:10';$('task-body').value='#!/usr/bin/env bash\nset -euo pipefail\n# 先确认已有域名和续签配置,且没有其他重复的自动续签计划。\nif command -v certbot >/dev/null 2>&1; then\n certbot renew --quiet\nelif [ -x /root/.acme.sh/acme.sh ]; then\n /root/.acme.sh/acme.sh --cron --home /root/.acme.sh\nelse\n echo "未检测到 Certbot 或 acme.sh,请先配置证书续签工具。" >&2\n exit 1\nfi\n# 如需重新加载 Web 服务,请按实际服务另行配置 deploy-hook。';$('task-template-hint').textContent='这是续签示例,不会安装或配置域名。请先确认 Certbot/acme.sh 的现有配置,并检查是否已有续签任务。';}
|
||
updateEditorFields();
|
||
});
|
||
$('task-import-file').addEventListener('click',()=>$('task-upload').click());
|
||
$('task-upload').addEventListener('change',async()=>{const file=$('task-upload').files[0];if(!file)return;if(file.size>65536){toast('脚本文件不能超过 64 KiB。');return;}$('task-mode').value='script';$('task-template').value='custom';$('task-interpreter').value=file.name.endsWith('.py')?'python3':'bash';$('task-body').value=await file.text();if(!$('task-name').value)$('task-name').value=file.name;updateEditorFields();$('task-upload').value='';});
|
||
async function openRuns(task=null){runsTask=task;selectedRun=null;$('task-runs-heading').textContent=task?'执行记录 · '+task.name:'全部任务执行记录';$('task-run-meta').textContent='选择一次执行,查看输出和错误。';$('task-run-output').textContent='尚未选择执行记录。';$('task-runs-dialog').showModal();await refreshRuns();}
|
||
async function refreshRuns(){
|
||
if(!auth.logged_in||!$('task-runs-dialog').open)return;
|
||
try{const data=await request(runsTask?'/runs?id='+encodeURIComponent(runsTask.id):'/history');const holder=$('task-runs-list');holder.replaceChildren();
|
||
if(!data.runs.length){holder.append(el('p','task-no-runs','还没有执行记录。可以等待计划时间,或手动执行一次。'));return;}
|
||
data.runs.forEach(run=>{const button=el('button','run-item'+(selectedRun===run.id?' selected':''));button.type='button';button.dataset.runId=run.id;button.append(stateBadge(run.state),el('strong','',when(run.started)),el('span','',run.task_name),el('small','',(run.trigger==='manual'?'手动执行':'定时执行')+' · '+(run.duration!=null?num(run.duration,1)+' 秒':run.state==='running'?'执行中':'耗时未记录')));button.addEventListener('click',()=>loadRun(run.id));holder.append(button);});
|
||
if(!selectedRun)selectedRun=data.runs[0].id;await loadRun(selectedRun);
|
||
}catch(error){if(error.status!==401)toast(error.message);}
|
||
}
|
||
async function loadRun(id){
|
||
selectedRun=id;const sequence=++runReadSequence;
|
||
try{const data=await request('/runlog?run_id='+encodeURIComponent(id));if(sequence!==runReadSequence)return;
|
||
$('task-run-meta').replaceChildren(stateBadge(data.run.state),el('span','',`开始 ${when(data.run.started)} · 退出码 ${data.run.exit_code??'尚未取得'} · 修订 ${data.run.revision}`));
|
||
const output=$('task-run-output'),stick=output.scrollHeight-output.scrollTop-output.clientHeight<50;output.textContent=(data.tail_only?'[仅显示最后 128 KiB]\n':'')+data.output;if(stick)output.scrollTop=output.scrollHeight;
|
||
$('task-runs-list').querySelectorAll('.run-item').forEach(button=>button.classList.toggle('selected',button.dataset.runId===id));
|
||
}catch(error){if(error.status!==401)$('task-run-output').textContent=error.message;}
|
||
}
|
||
$('task-all-runs').addEventListener('click',()=>openRuns());
|
||
$('task-change-password').addEventListener('click',()=>{$('task-password-form').reset();taskError('task-password-error','');$('task-password-dialog').showModal();});
|
||
$('task-password-form').addEventListener('submit',async event=>{event.preventDefault();taskError('task-password-error','');if($('task-new-password').value!==$('task-repeat-password').value){taskError('task-password-error','两次新密码输入不一致。');return;}const button=event.submitter;button.disabled=true;try{const result=await request('/password',{old_password:$('task-old-password').value,new_password:$('task-new-password').value});$('task-password-dialog').close();setAuth({logged_in:false});toast(result.message);}catch(error){taskError('task-password-error',error.message);}finally{button.disabled=false;}});
|
||
refreshAuth();setInterval(()=>{if(!document.hidden&&auth.logged_in)refreshList();},15000);setInterval(()=>{if(!document.hidden&&$('task-runs-dialog').open)refreshRuns();},3000);
|
||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refreshAuth();});
|
||
})();
|