alphax/static/cron.html
2026-05-15 11:50:26 +08:00

63 lines
12 KiB
HTML
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.

{% extends "base.html" %}
{% block title %}AlphaX Agent Crypto — 调度中心{% endblock %}
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
<a class="sidebar-link active admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
{% 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}