feat: 调度简单设置(频率选择器)+ 运行窗口 + 下次运行列

This commit is contained in:
2026-08-11 08:27:33 +08:00
parent c37290e8e3
commit 794f40a880
4 changed files with 222 additions and 26 deletions
+72 -1
View File
@@ -21,6 +21,7 @@ import json
import time
import uuid
import threading
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
from apscheduler.schedulers.background import BackgroundScheduler
@@ -45,6 +46,61 @@ _log = get_logger("core.tm")
_START_STAGGER_SEC = 0.2
def _in_run_window(schedule, now=None):
"""是否在当前运行窗口内。
schedule.window = {"start": "HH:MM", "end": "HH:MM"},每天重复;
窗口外(定时触发 + 手动执行)任务不会启动。未配置/非法配置视为不限制。
支持跨午夜(如 21:00-09:00 = 晚上 9 点运行到次日早 9 点)。
"""
win = (schedule or {}).get("window") or {}
start, end = win.get("start", ""), win.get("end", "")
if not start or not end:
return True
try:
cur = (now or datetime.now()).hour * 60 + (now or datetime.now()).minute
s = int(start.split(":")[0]) * 60 + int(start.split(":")[1])
e = int(end.split(":")[0]) * 60 + int(end.split(":")[1])
except (ValueError, AttributeError, IndexError):
return True # 配置非法按不限制处理
if s == e:
return True # 起止相同视为不限制
if s < e:
return s <= cur < e
return cur >= s or cur < e # 跨午夜
def _next_run_time(schedule, now=None):
"""任务下次真正执行的时间(考虑运行窗口)。
用 CronTrigger.get_next_fire_time 从当前时间向后找触发点,
跳过运行窗口外的触发点(最多找 200 次防死循环)。
返回 datetime 或 None(无 cron / 配置非法)。
"""
sched = schedule or {}
if sched.get("mode") not in ("cron", "cron_stop"):
return None
cron = sched.get("cron", "")
if not cron:
return None
try:
trigger = CronTrigger.from_crontab(cron)
except Exception:
return None
now = now or datetime.now()
win = sched.get("window") or {}
if not win.get("start") or not win.get("end"):
return trigger.get_next_fire_time(None, now)
fire = trigger.get_next_fire_time(None, now)
for _ in range(200):
if fire is None:
return None
if _in_run_window({"window": win}, fire):
return fire
fire = trigger.get_next_fire_time(fire, fire)
return None
# ================== 设备分组 ==================
class DeviceGroup:
def __init__(self, name, serials=None, description=""):
@@ -504,6 +560,11 @@ class TaskManager:
job = self.jobs.get(job_id)
if not job:
return
if not _in_run_window(job.schedule):
win = job.schedule.get("window") or {}
_log.info(f"定时触发 {job.name}({job.id}) 跳过:当前不在运行窗口内 "
f"({win.get('start','')}-{win.get('end','')})")
return
_log.info(f"定时触发: {job.name}({job.id})")
self._run_job(job)
@@ -526,11 +587,21 @@ class TaskManager:
stopped.append(serial)
_log.info(f"定时停止 {job.name}({job.id}): 停止 {len(stopped)} 台设备 {stopped}")
def next_run_of(self, job):
"""任务下次真正执行的时间(考虑运行窗口)。停用/无 cron 返回 None。"""
if not job or not job.enabled:
return None
return _next_run_time(job.schedule)
def run_job_now(self, job_id):
"""立即执行任务(手动触发)。"""
"""立即执行任务(手动触发)。运行窗口外拒绝启动。"""
job = self.jobs.get(job_id)
if not job:
return {"ok": False, "error": "任务不存在"}
if not _in_run_window(job.schedule):
win = job.schedule.get("window") or {}
return {"ok": False,
"error": f"当前不在运行窗口内({win.get('start', '')}-{win.get('end', '')}),任务未启动"}
# 在独立线程跑,不阻塞调用方
t = threading.Thread(target=self._run_job, args=(job,), daemon=True)
t.start()
+124 -14
View File
@@ -498,6 +498,13 @@ async function loadJobsForMonitor(){
// 通用进度展示:解析 progress = {done, total, unit, action_counts, elapsed}
// 任意 app 都能用:显示进度条 + "done/total unit" + 运行时长 + 操作计数徽章
// 下次运行时间格式化:"2026-08-12 09:00:00" → "08-12 09:00"
function fmtNextRun(iso){
if(!iso)return '';
const s=String(iso).replace('T',' ');
return s.length>=16?s.slice(5,16):s;
}
function fmtProgress(progress, workerStatus){
if(!progress||!Object.keys(progress).length){
// 空闲/未运行 → 显示"-"
@@ -622,7 +629,7 @@ function renderTaskTable(){
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
render:renderTaskRow,
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
empty:'<tr><td colspan="8" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
empty:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
});
applyListPager('tasks');
initListSearch('tasks','search-tasks');
@@ -630,8 +637,14 @@ function renderTaskTable(){
function renderTaskRow(j){
const tgt=TARGET_MAP[j.target.mode]||j.target.mode;
const tgtDetail=j.target.mode==='group'?(j.target.group_name||''):(j.target.mode==='serial'?(j.target.serial||''):'');
const sched=j.schedule.mode==='cron_stop'?('启停 '+esc(j.schedule.cron||'')+'→'+esc(j.schedule.stop_cron||'')):
j.schedule.mode==='cron'?('定时 '+esc(j.schedule.cron||'')):'手动';
const sched=j.schedule.mode==='cron_stop'?('启停 '+cronToText(j.schedule.cron||'')+' → '+cronToText(j.schedule.stop_cron||'')):
j.schedule.mode==='cron'?('定时 '+cronToText(j.schedule.cron||'')):'手动';
const schedTxt=sched+((j.schedule.window&&j.schedule.window.start&&j.schedule.window.end)
?' [窗口 '+esc(j.schedule.window.start)+'-'+esc(j.schedule.window.end)+']':'');
const schedOut='<td>'+schedTxt+'</td>';
// 下次运行时间(后端已按运行窗口跳过窗口外触发点)
const nextRun=fmtNextRun(j.next_run);
const nextOut='<td>'+(nextRun||'<span class="text-muted">-</span>')+'</td>';
const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试';
const en=j.enabled?'<span class="label label-success">启用</span>':'<span class="label label-default">停用</span>';
return '<tr>'+
@@ -640,13 +653,16 @@ function renderTaskRow(j){
'<td>'+esc(j.task_type)+'</td>'+
'<td>'+en+'</td>'+
'<td>'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+'</td>'+
'<td>'+sched+'</td>'+
schedOut+
nextOut+
'<td>'+retry+'</td>'+
'<td>'+
(_can('tasks')?(
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">执行</button>'+
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用':'启用')+'</button>'+
'<button class="btn btn-xs" onclick="openTaskModal(\''+j.id+'\')">编辑</button>'+
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'+
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'
):'<span class="text-muted">只读</span>')+
'</td>'+
'</tr>';
}
@@ -663,6 +679,15 @@ function openTaskModal(jobId){
const sm=j.schedule.mode;
const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):'';
const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):'';
// 运行窗口(每天时间段,窗口外不启动)
const win=j.schedule.window||null;
const winStart=win?(win.start||'09:00'):'09:00';
const winEnd=win?(win.end||'18:00'):'18:00';
// 简单调度:把已有 cron 解析回"频率+时间",编辑时回填;解析不了落到自定义
const sc=parseCronToFreq(startCron);
const stopF=parseCronToFreq(stopCron).time;
const weekdayOpts=[0,1,2,3,4,5,6].map(w=>
'<option value="'+w+'"'+(sc.weekday===w?' selected':'')+'>'+WEEKDAY_NAMES[w]+'</option>').join('');
document.getElementById('modal-body').innerHTML=
'<div class="form-row">'+
@@ -686,14 +711,32 @@ function openTaskModal(jobId){
'<div class="form-row">'+
'<div class="form-group"><label>调度方式</label>'+
'<select class="form-control" id="f-schedule_mode" onchange="toggleScheduleFields()">'+
'<option value="once"'+(sm==='once'?' selected':'')+'>手动执行</option>'+
'<option value="once"'+(sm==='once'?' selected':'')+'>手动(不自动启动)</option>'+
'<option value="cron"'+(sm==='cron'?' selected':'')+'>定时启动</option>'+
'<option value="cron_stop"'+(sm==='cron_stop'?' selected':'')+'>定时启停</option>'+
'<option value="cron_stop"'+(sm==='cron_stop'?' selected':'')+'>定时启动+停止</option>'+
'</select>'+
'</div>'+
'<div class="form-group" id="f-cron_wrap"><label>启动 Cron 表达式</label><input type="text" class="form-control" id="f-schedule_cron" value="'+esc(startCron)+'" placeholder="如 0 9 * * * = 每天9点启动" style="font-family:monospace"><div class="help">格式:分 时 日 月 周</div></div>'+
'<div class="form-group" id="f-stop_cron_wrap"><label>停止 Cron 表达式</label><input type="text" class="form-control" id="f-schedule_stop_cron" value="'+esc(stopCron)+'" placeholder="如 0 18 * * * = 每天18点停止" style="font-family:monospace"><div class="help">到点自动停止所有运行中的 worker</div></div>'+
'<div class="form-group" id="f-freq_wrap" style="display:none"><label>频率</label>'+
'<select class="form-control" id="f-schedule_freq" onchange="toggleScheduleFields()">'+
'<option value="daily"'+((sc.freq==='daily')?' selected':'')+'>每天</option>'+
'<option value="hourly"'+((sc.freq==='hourly')?' selected':'')+'>每小时(整点)</option>'+
'<option value="everyN"'+((sc.freq==='everyN')?' selected':'')+'>每隔 N 小时</option>'+
'<option value="weekly"'+((sc.freq==='weekly')?' selected':'')+'>每周</option>'+
'<option value="custom"'+((sc.freq==='custom')?' selected':'')+'>自定义 cron(高级)</option>'+
'</select>'+
'</div>'+
'<div class="form-group" id="f-time_wrap" style="display:none"><label>启动时间</label><input type="time" class="form-control" id="f-schedule_time" value="'+esc(sc.time||'09:00')+'"></div>'+
'<div class="form-group" id="f-interval_wrap" style="display:none"><label>间隔(小时)</label><input type="number" class="form-control" id="f-schedule_interval" value="'+(sc.interval||2)+'" min="1" max="12"><div class="help">从 0 点起算,如 2 = 0/2/4/6...点启动</div></div>'+
'<div class="form-group" id="f-weekday_wrap" style="display:none"><label>星期</label><select class="form-control" id="f-schedule_weekday">'+weekdayOpts+'</select></div>'+
'<div class="form-group" id="f-cron_wrap" style="display:none"><label>启动 Cron 表达式</label><input type="text" class="form-control" id="f-schedule_cron" value="'+esc(startCron)+'" placeholder="如 0 9 * * * = 每天9点启动" style="font-family:monospace"><div class="help">格式:分 时 日 月 周(0 0 * * 0 = 每周日零点)</div></div>'+
'<div class="form-group" id="f-stop_wrap" style="display:none"><label>停止时间(每天)</label><input type="time" class="form-control" id="f-schedule_stop_time" value="'+esc(stopF||'18:00')+'"><div class="help">到点自动停止所有运行中的 worker</div></div>'+
'</div>'+
'<div class="form-group form-check" style="margin-top:4px"><input type="checkbox" id="f-window_enable" '+(win?'checked':'')+' onchange="toggleWindowFields()"><label for="f-window_enable">启用运行窗口(只在指定时间段内运行)</label></div>'+
'<div class="form-row" id="f-window_wrap" style="display:'+(win?'flex':'none')+'">'+
'<div class="form-group"><label>允许运行·开始</label><input type="time" class="form-control" id="f-window_start" value="'+esc(winStart)+'"></div>'+
'<div class="form-group"><label>允许运行·结束</label><input type="time" class="form-control" id="f-window_end" value="'+esc(winEnd)+'"></div>'+
'</div>'+
'<div class="help" id="f-window_help" style="display:'+(win?'block':'none')+'">窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行</div>'+
'<div class="form-row">'+
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
@@ -728,10 +771,69 @@ function toggleTargetFields(){
document.getElementById('f-target_serial_wrap').style.display=mode==='serial'?'block':'none';
}
// ===== 简单调度:cron(5段) ↔ 频率+时间 互转 =====
const WEEKDAY_NAMES=['周日','周一','周二','周三','周四','周五','周六'];
// cron → 简单设置 {freq: daily|hourly|everyN|weekly|custom, time:"HH:MM", interval, weekday(0-6)}
function parseCronToFreq(cron){
const r={freq:'custom',time:'',interval:2,weekday:1};
const p=(cron||'').trim().split(/\s+/);
if(p.length!==5)return r;
const [mi,h,d,m,w]=p;
if(mi==='0'&&h==='*'&&d==='*'&&m==='*'&&w==='*'){r.freq='hourly';return r;}
const n=(h.match(/^\*\/(\d+)$/)||[])[1]; // 每隔N小时:时位是 */N
if(n&&mi==='0'&&d==='*'&&m==='*'&&w==='*'){r.freq='everyN';r.interval=parseInt(n)||2;return r;}
if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&w==='*'){
r.freq='daily';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');return r;
}
if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&/^[0-7]$/.test(w)){
r.freq='weekly';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');r.weekday=parseInt(w)%7;return r;
}
return r; // 解析不了 → 落到自定义
}
// 表单 → cron(5段)
function buildCronFromFreq(){
const freq=document.getElementById('f-schedule_freq').value;
if(freq==='custom')return document.getElementById('f-schedule_cron').value.trim();
if(freq==='hourly')return '0 * * * *';
if(freq==='everyN'){
const n=parseInt(document.getElementById('f-schedule_interval').value)||2;
return '0 */'+Math.min(Math.max(n,1),12)+' * * *';
}
const t=(document.getElementById('f-schedule_time').value||'09:00').split(':');
const mi=parseInt(t[1])||0, h=parseInt(t[0])||0;
if(freq==='weekly'){
const w=parseInt(document.getElementById('f-schedule_weekday').value)||0;
return mi+' '+h+' * * '+w;
}
return mi+' '+h+' * * *'; // daily
}
// cron(5段) → 中文描述(任务列表显示)
function cronToText(cron){
const r=parseCronToFreq(cron||'');
if(r.freq==='hourly')return '每小时';
if(r.freq==='everyN')return '每'+r.interval+'小时';
if(r.freq==='daily')return '每天 '+r.time;
if(r.freq==='weekly')return WEEKDAY_NAMES[r.weekday]+' '+r.time;
return 'cron: '+(cron||'-');
}
function toggleScheduleFields(){
const mode=document.getElementById('f-schedule_mode').value;
document.getElementById('f-cron_wrap').style.display=(mode==='cron'||mode==='cron_stop')?'block':'none';
document.getElementById('f-stop_cron_wrap').style.display=mode==='cron_stop'?'block':'none';
const showSched=(mode==='cron'||mode==='cron_stop');
document.getElementById('f-freq_wrap').style.display=showSched?'block':'none';
document.getElementById('f-stop_wrap').style.display=mode==='cron_stop'?'block':'none';
if(showSched)showFreqFields();
}
function toggleWindowFields(){
const on=document.getElementById('f-window_enable').checked;
document.getElementById('f-window_wrap').style.display=on?'flex':'none';
document.getElementById('f-window_help').style.display=on?'block':'none';
}
function showFreqFields(){
const freq=document.getElementById('f-schedule_freq').value;
document.getElementById('f-time_wrap').style.display=(freq==='daily'||freq==='weekly')?'block':'none';
document.getElementById('f-interval_wrap').style.display=freq==='everyN'?'block':'none';
document.getElementById('f-weekday_wrap').style.display=freq==='weekly'?'block':'none';
document.getElementById('f-cron_wrap').style.display=freq==='custom'?'block':'none';
}
function onTaskTypeChange(){
@@ -1726,11 +1828,19 @@ async function saveTask(jobId){
const schedMode=document.getElementById('f-schedule_mode').value;
let schedule={mode:'once'};
if(schedMode==='cron'){
schedule={mode:'cron',cron:document.getElementById('f-schedule_cron').value.trim()};
schedule={mode:'cron',cron:buildCronFromFreq()};
}else if(schedMode==='cron_stop'){
const st=(document.getElementById('f-schedule_stop_time').value||'18:00').split(':');
schedule={mode:'cron_stop',
cron:document.getElementById('f-schedule_cron').value.trim(),
stop_cron:document.getElementById('f-schedule_stop_cron').value.trim()};
cron:buildCronFromFreq(),
stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'};
}
// 运行窗口:窗口外(定时触发 + 手动执行)不启动
if(document.getElementById('f-window_enable').checked){
schedule.window={
start:document.getElementById('f-window_start').value||'09:00',
end:document.getElementById('f-window_end').value||'18:00',
};
}
const data={
+2 -2
View File
@@ -337,13 +337,13 @@ select.form-control{cursor:pointer}
<div class="page-title">任务计划管理</div>
<div class="page-sub">新建、编辑、删除任务计划,支持立即执行和定时调度</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="openTaskModal()">新建任务</button>
<button class="btn btn-primary" onclick="openTaskModal()" data-perm="tasks">新建任务</button>
<button class="btn" onclick="loadTasks()">刷新</button>
<input class="form-control list-search" id="search-tasks" placeholder="搜索任务名/类型/ID...">
</div>
<table class="table">
<thead><tr>
<th>ID</th><th>任务名</th><th>类型</th><th>启用</th><th>目标</th><th>调度</th><th>重试</th><th>操作</th>
<th>ID</th><th>任务名</th><th>类型</th><th>启用</th><th>目标</th><th>调度</th><th>下次运行</th><th>重试</th><th>操作</th>
</tr></thead>
<tbody id="tb-tasks"></tbody>
</table>
+21 -6
View File
@@ -247,15 +247,26 @@ def api_devices():
# ================== API:任务计划 CRUD ==================
def _job_next_run(job):
"""任务下次执行时间,格式化为 "YYYY-MM-DD HH:MM"(Flask 序列化 aware datetime 会变 GMT 格式)。"""
nr = mgr.next_run_of(job)
return nr.strftime("%Y-%m-%d %H:%M") if nr else None
@app.route("/api/jobs")
@login_required
def api_jobs_list():
return jsonify({"ok": True, "jobs": [j.to_dict() for j in mgr.jobs.values()],
jobs = []
for j in mgr.jobs.values():
d = j.to_dict()
d["next_run"] = _job_next_run(j)
jobs.append(d)
return jsonify({"ok": True, "jobs": jobs,
"task_types": list_task_types()})
@app.route("/api/jobs", methods=["POST"])
@login_required
@perm_required(PERM_TASKS)
def api_jobs_create():
data = request.json or {}
name = (data.get("name") or "").strip()
@@ -271,11 +282,13 @@ def api_jobs_create():
schedule=data.get("schedule", {"mode": "once"}),
retry=data.get("retry", {"max_attempts": 1, "delay": 60}),
enabled=data.get("enabled", True))
return jsonify({"ok": True, "msg": "任务已创建", "job": job.to_dict()})
d = job.to_dict()
d["next_run"] = _job_next_run(job)
return jsonify({"ok": True, "msg": "任务已创建", "job": d})
@app.route("/api/jobs/<job_id>", methods=["PUT"])
@login_required
@perm_required(PERM_TASKS)
def api_jobs_update(job_id):
job = mgr.jobs.get(job_id)
if not job:
@@ -288,11 +301,13 @@ def api_jobs_update(job_id):
if "task_type" in fields and not get_task_class(fields["task_type"]):
return jsonify({"ok": False, "error": f"未知任务类型: {fields['task_type']}"}), 400
mgr.update_job(job_id, **fields)
return jsonify({"ok": True, "msg": "任务已更新", "job": job.to_dict()})
d = job.to_dict()
d["next_run"] = _job_next_run(job)
return jsonify({"ok": True, "msg": "任务已更新", "job": d})
@app.route("/api/jobs/<job_id>", methods=["DELETE"])
@login_required
@perm_required(PERM_TASKS)
def api_jobs_delete(job_id):
if mgr.delete_job(job_id):
return jsonify({"ok": True, "msg": "任务已删除"})