49 lines
10 KiB
HTML
49 lines
10 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}AlphaX Agent — 调度中心{% endblock %}
|
|
{% block extra_head_css %}
|
|
<style>
|
|
.shell{width:min(100% - 40px,1280px);margin:0 auto;padding:24px 0 44px}.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px}.page-head h1{font-size:26px;font-weight:900;color:var(--ink)}.page-head p{font-size:13px;color:var(--stone);margin-top:4px}.actions{display:flex;gap:8px;align-items:center}.btn{height:36px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:12px;font-weight:900;color:var(--ink);cursor:pointer}.btn.primary{background:var(--primary);border-color:var(--primary);color:var(--on-primary)}.btn.warn{color:var(--red)}.btn:disabled{opacity:.45;cursor:default}.grid{display:grid;grid-template-columns:1fr;gap:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden}.panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:900;color:var(--ink)}.panel-note{font-size:11px;font-weight:800;color:var(--stone)}.job-table{width:100%;border-collapse:collapse;min-width:1040px}.job-table th,.job-table td{padding:11px 10px;border-bottom:1px solid var(--hairline-soft);text-align:left;font-size:12px;vertical-align:middle}.job-table th{font-size:11px;color:var(--stone);font-weight:900;background:var(--surface)}.job-table tr:last-child td{border-bottom:0}.table-wrap{overflow:auto}.job-name{font-weight:900;color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.desc{color:var(--stone);font-size:11px;margin-top:3px}.badge{display:inline-flex;align-items:center;height:24px;border-radius:999px;padding:0 8px;font-size:11px;font-weight:900;border:1px solid var(--hairline-soft);background:var(--surface);color:var(--slate);white-space:nowrap}.badge.ok{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.badge.err{background:var(--red-light);border-color:rgba(229,62,62,.18);color:var(--red)}.badge.run{background:rgba(66,98,255,.08);border-color:rgba(66,98,255,.18);color:var(--blue)}.badge.wait{background:rgba(245,158,11,.1);border-color:rgba(245,158,11,.22);color:#b7791f}.interval{width:82px;height:34px;border:1px solid var(--hairline-strong);border-radius:var(--radius-md);padding:0 8px;font-size:12px;font-weight:800;background:var(--canvas);color:var(--ink)}.mini{font-size:11px;color:var(--stone);line-height:1.5}.switch{display:inline-flex;align-items:center;gap:7px}.switch input{width:34px;height:20px;appearance:none;border-radius:999px;background:var(--hairline);position:relative;cursor:pointer}.switch input:checked{background:var(--green)}.switch input:before{content:"";position:absolute;top:3px;left:3px;width:14px;height:14px;border-radius:50%;background:white;transition:.15s}.switch input:checked:before{left:17px}.trigger-list{display:grid;gap:8px;padding:12px}.trigger{display:grid;grid-template-columns:140px 90px 1fr auto;gap:10px;align-items:center;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:10px}.out{color:var(--slate);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty,.loading{padding:28px 16px;text-align:center;color:var(--stone);font-size:13px}.toast{position:fixed;right:18px;bottom:18px;background:var(--ink);color:white;border-radius:var(--radius-md);padding:10px 12px;font-size:12px;font-weight:800;opacity:0;transform:translateY(8px);transition:.18s;z-index:20}.toast.show{opacity:1;transform:translateY(0)}@media(max-width:760px){.shell{width:min(100% - 24px,1280px)}.trigger{grid-template-columns:1fr}.actions{width:100%;justify-content:flex-start}}
|
|
</style>
|
|
{% endblock %}
|
|
{% block content %}
|
|
<div class="shell">
|
|
<div class="page-head">
|
|
<div><h1>调度中心</h1><p>查看 Docker scheduler 当前状态,控制任务启停、周期与手动触发。</p></div>
|
|
<div class="actions"><button class="btn" onclick="loadAll()">刷新</button></div>
|
|
</div>
|
|
<div class="grid">
|
|
<section class="panel">
|
|
<div class="panel-head"><div class="panel-title">任务</div><div class="panel-note" id="updatedAt">--</div></div>
|
|
<div class="table-wrap"><table class="job-table"><thead><tr><th>任务</th><th>状态</th><th>启用</th><th>周期</th><th>下次运行</th><th>最近结果</th><th>运行信息</th><th>操作</th></tr></thead><tbody id="jobRows"><tr><td colspan="8" class="loading">加载中...</td></tr></tbody></table></div>
|
|
</section>
|
|
<section class="panel">
|
|
<div class="panel-head"><div class="panel-title">手动触发记录</div><div class="panel-note">最近 30 条</div></div>
|
|
<div id="triggerRows" class="trigger-list"><div class="loading">加载中...</div></div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
<div class="toast" id="toast"></div>
|
|
{% endblock %}
|
|
{% block extra_script %}
|
|
<script>
|
|
function $(id){return document.getElementById(id)}
|
|
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
|
|
function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime()))return t;return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0');}
|
|
function fmtDur(ms){ms=Number(ms||0);if(ms>=60000)return Math.round(ms/60000)+'m';if(ms>=1000)return (ms/1000).toFixed(1)+'s';return ms?ms+'ms':'--';}
|
|
function statusBadge(s){var cls=s==='running'?'run':s==='pending'?'wait':s==='disabled'?'err':'ok';var map={running:'运行中',pending:'等待锁',disabled:'已关闭',idle:'空闲',success:'成功',error:'失败',queued:'排队'};return '<span class="badge '+cls+'">'+esc(map[s]||s||'未知')+'</span>'}
|
|
function showToast(msg){var t=$('toast');t.textContent=msg;t.classList.add('show');setTimeout(function(){t.classList.remove('show')},1800)}
|
|
async function api(url,opts){var r=await fetch(url,opts||{});var d=await r.json().catch(function(){return {}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
|
|
async function loadAll(){await Promise.all([loadJobs(),loadTriggers()])}
|
|
async function loadJobs(){try{var d=await api('/api/scheduler/jobs');$('updatedAt').textContent='更新 '+fmtTime(d.updated_at);var rows=d.jobs||[];$('jobRows').innerHTML=rows.map(renderJob).join('')||'<tr><td colspan="8" class="empty">暂无任务</td></tr>'}catch(e){$('jobRows').innerHTML='<tr><td colspan="8" class="empty">'+esc(e.message)+'</td></tr>'}}
|
|
function renderJob(j){var rt=j.runtime||{},last=j.latest_cron||{};var st=rt.status||(j.enabled?'idle':'disabled');var result=last.run_status?statusBadge(last.run_status)+ '<div class="mini">'+esc(last.result_status||'')+' · '+fmtDur(last.duration_ms)+'</div>':'<span class="mini">暂无 cron 结果</span>';var running='<div class="mini">PID '+esc(rt.pid||'--')+' · '+esc(rt.locked_by||j.lock_group||'--')+'</div><div class="mini">'+esc(rt.last_error||'')+'</div>';var name=esc(j.job_name);return '<tr data-job="'+name+'"><td><div class="job-name">'+name+'</div><div class="desc">'+esc(j.description||j.command)+'</div></td><td>'+statusBadge(st)+'</td><td><label class="switch"><input class="job-enabled" data-job="'+name+'" type="checkbox" '+(j.enabled?'checked':'')+'><span>'+(j.enabled?'开':'关')+'</span></label></td><td><input class="interval job-interval" data-job="'+name+'" type="number" min="30" value="'+esc(j.every_seconds)+'"> 秒<br><button class="btn" data-action="save-interval" data-job="'+name+'">保存</button></td><td>'+fmtTime(rt.next_run_at)+'</td><td>'+result+'</td><td>'+running+'</td><td><button class="btn primary" data-action="trigger" data-job="'+name+'" data-enabled="'+(j.enabled?'1':'0')+'">立即运行</button></td></tr>'}
|
|
async function toggleJob(name,en){try{await api('/api/scheduler/jobs/'+encodeURIComponent(name)+'/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:en})});showToast('已更新 '+name);loadJobs()}catch(e){showToast(e.message);loadJobs()}}
|
|
function getIntervalInput(name){var inputs=document.querySelectorAll('.job-interval');for(var i=0;i<inputs.length;i++){if(inputs[i].dataset.job===name)return inputs[i]}return null}
|
|
async function saveInterval(name){var input=getIntervalInput(name);var v=Number((input&&input.value)||0);try{await api('/api/scheduler/jobs/'+encodeURIComponent(name)+'/interval',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({every_seconds:v})});showToast('周期已保存');loadJobs()}catch(e){showToast(e.message)}}
|
|
async function triggerJob(name,enabled){var force=false;if(!enabled){force=confirm('任务当前已关闭,仍要单次运行 '+name+' 吗?');if(!force)return}try{var d=await api('/api/scheduler/jobs/'+encodeURIComponent(name)+'/trigger',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({force:force})});showToast('已入队 #'+d.trigger_id);loadAll()}catch(e){showToast(e.message)}}
|
|
async function loadTriggers(){try{var d=await api('/api/scheduler/triggers?limit=30');var rows=d.items||[];$('triggerRows').innerHTML=rows.map(function(x){return '<div class="trigger"><div><b class="job-name">'+esc(x.job_name)+'</b><div class="mini">'+fmtTime(x.requested_at)+'</div></div><div>'+statusBadge(x.status)+'</div><div class="out">'+esc(x.error_message||x.output_tail||'--')+'</div><div class="mini">'+fmtDur(x.duration_ms)+'</div></div>'}).join('')||'<div class="empty">暂无手动触发</div>'}catch(e){$('triggerRows').innerHTML='<div class="empty">'+esc(e.message)+'</div>'}}
|
|
$('jobRows').addEventListener('change',function(e){if(e.target&&e.target.classList.contains('job-enabled'))toggleJob(e.target.dataset.job,e.target.checked)})
|
|
$('jobRows').addEventListener('click',function(e){var btn=e.target.closest('[data-action]');if(!btn)return;if(btn.dataset.action==='save-interval')saveInterval(btn.dataset.job);if(btn.dataset.action==='trigger')triggerJob(btn.dataset.job,btn.dataset.enabled==='1')})
|
|
loadAll();setInterval(loadAll,5000);
|
|
</script>
|
|
{% endblock %}
|