hardware-dashboard/static/tasks.js

177 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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();});
})();