80 lines
14 KiB
HTML
80 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}运行大屏 - AlphaX Agent{% endblock %}
|
|
{% block extra_head_css %}
|
|
<style>
|
|
.ops-wrap{width:min(100% - 40px,1680px);margin:0 auto;padding:22px 0 36px;color:var(--ink)}.ops-head{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px;align-items:start;margin-bottom:16px}.ops-kicker{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--stone);font-weight:900}.ops-title{font-size:28px;line-height:1.12;font-weight:950;margin-top:4px;letter-spacing:0;color:var(--ink)}.ops-sub{margin-top:6px;color:var(--stone);font-size:13px;line-height:1.55}.ops-actions{display:flex;gap:8px;align-items:center}.ops-select,.ops-btn{height:38px;border:1px solid var(--hairline-strong);border-radius:var(--radius-md);background:var(--canvas);color:var(--ink);font-weight:850;padding:0 12px}.ops-btn{cursor:pointer}.hero-grid{display:grid;grid-template-columns:1.15fr repeat(5,minmax(0,1fr));gap:10px;margin-bottom:14px}.health-card,.metric-card,.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);box-shadow:none}.health-card{padding:16px;display:flex;gap:14px;align-items:center;min-height:106px}.status-orb{width:48px;height:48px;border-radius:50%;position:relative;display:grid;place-items:center;background:var(--green-light);border:1px solid rgba(0,180,115,.22);flex:0 0 auto}.status-orb:before{content:"";width:13px;height:13px;border-radius:50%;background:var(--green);box-shadow:0 0 0 6px rgba(0,180,115,.08)}.status-orb.warn{background:var(--yellow-light);border-color:rgba(252,185,0,.24)}.status-orb.warn:before{background:var(--yellow-deep);box-shadow:0 0 0 6px rgba(252,185,0,.12)}.status-orb.danger{background:var(--red-light);border-color:rgba(229,62,62,.24)}.status-orb.danger:before{background:var(--red);box-shadow:0 0 0 6px rgba(229,62,62,.1)}.health-label{font-size:22px;font-weight:950;color:var(--ink)}.health-note{margin-top:4px;color:var(--stone);font-size:13px}.metric-card{padding:14px;min-width:0}.metric-card span{display:block;color:var(--stone);font-size:11px;font-weight:900}.metric-card b{display:block;margin-top:9px;font-size:24px;line-height:1;font-weight:950;color:var(--ink)}.metric-card small{display:block;margin-top:7px;color:var(--stone);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.main-grid{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(380px,.85fr);gap:14px}.panel{padding:15px;min-width:0}.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:13px}.panel-title{font-size:15px;font-weight:950;color:var(--ink)}.panel-note{font-size:11px;color:var(--stone);margin-top:4px}.badge{display:inline-flex;align-items:center;height:24px;border-radius:var(--radius-full);padding:0 9px;border:1px solid var(--hairline-soft);font-size:11px;font-weight:950;color:var(--stone);background:var(--surface);white-space:nowrap}.badge.ok{color:var(--green);border-color:rgba(0,180,115,.18);background:var(--green-light)}.badge.warn{color:var(--yellow-dark);border-color:rgba(252,185,0,.26);background:var(--yellow-light)}.badge.danger{color:var(--red);border-color:rgba(229,62,62,.18);background:var(--red-light)}.badge.running{color:var(--blue);border-color:rgba(66,98,255,.18);background:rgba(66,98,255,.08)}.flow{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px}.flow-node{position:relative;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:12px;min-height:106px}.flow-node:not(:last-child):after{content:"";position:absolute;right:-8px;top:50%;width:8px;height:1px;background:var(--hairline-strong)}.flow-node .dot{width:9px;height:9px;border-radius:50%;background:var(--green)}.flow-node.warn .dot{background:var(--yellow-deep)}.flow-node.danger .dot{background:var(--red)}.flow-label{margin-top:10px;color:var(--stone);font-size:11px;font-weight:950}.flow-value{margin-top:7px;font-size:26px;font-weight:950;color:var(--ink)}.flow-sub{margin-top:6px;color:var(--stone);font-size:11px;line-height:1.35}.split{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:14px}.heartbeats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.beat{border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:10px}.beat-top{display:flex;justify-content:space-between;gap:8px;align-items:center}.beat-name{font-weight:950;font-size:12px;color:var(--ink)}.beat-meta{margin-top:7px;color:var(--stone);font-size:11px;line-height:1.45}.source-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.source{border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:11px}.source-name{font-size:12px;font-weight:950;color:var(--ink)}.source-count{font-size:23px;font-weight:950;margin-top:8px;color:var(--ink)}.source-meta{font-size:11px;color:var(--stone);margin-top:6px}.trade-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.trade-stat{border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:11px}.trade-stat span{font-size:11px;color:var(--stone);font-weight:900}.trade-stat b{display:block;font-size:22px;margin-top:7px;color:var(--ink)}.timeline{display:grid;gap:8px;max-height:calc(100vh - 160px);overflow:auto;padding-right:4px}.event{display:grid;grid-template-columns:80px minmax(0,1fr);gap:10px;align-items:start;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:10px}.event-time{font-size:11px;color:var(--stone);font-weight:900}.event-title{font-size:12px;font-weight:950;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.event-detail{margin-top:4px;color:var(--stone);font-size:11px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty,.loading{padding:28px;text-align:center;color:var(--stone);font-size:13px}.warn-text{color:var(--yellow-dark)}.red-text{color:var(--red)}.green-text{color:var(--green)}@media(max-width:1280px){.hero-grid{grid-template-columns:1fr 1fr 1fr}.health-card{grid-column:1/-1}.main-grid,.split{grid-template-columns:1fr}.flow{grid-template-columns:repeat(3,minmax(0,1fr))}.source-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:720px){.ops-wrap{width:min(100% - 24px,1440px)}.ops-head{grid-template-columns:1fr}.hero-grid,.flow,.heartbeats,.source-grid,.trade-grid{grid-template-columns:1fr}.flow-node:after{display:none}}
|
|
</style>
|
|
{% endblock %}
|
|
{% block content %}
|
|
<div class="ops-wrap">
|
|
<header class="ops-head">
|
|
<div>
|
|
<div class="ops-kicker">Operations Command</div>
|
|
<div class="ops-title">AlphaX 运行大屏</div>
|
|
<div class="ops-sub">用一张图看懂调度、数据源、机会漏斗、策略交易和异常状态。</div>
|
|
</div>
|
|
<div class="ops-actions">
|
|
<select class="ops-select" id="hoursSel" onchange="loadOps()">
|
|
<option value="6">近 6h</option>
|
|
<option value="24" selected>近 24h</option>
|
|
<option value="72">近 72h</option>
|
|
<option value="168">近 7 天</option>
|
|
</select>
|
|
<button class="ops-btn" onclick="loadOps()">刷新</button>
|
|
</div>
|
|
</header>
|
|
<section class="hero-grid" id="hero"><div class="loading">加载中...</div></section>
|
|
<main class="main-grid">
|
|
<div>
|
|
<section class="panel">
|
|
<div class="panel-head"><div><div class="panel-title">机会链路漏斗</div><div class="panel-note">从数据进入到策略交易账本的实时链路状态</div></div><span class="badge" id="generatedAt">--</span></div>
|
|
<div class="flow" id="funnel"></div>
|
|
</section>
|
|
<div class="split">
|
|
<section class="panel">
|
|
<div class="panel-head"><div><div class="panel-title">任务心跳</div><div class="panel-note">scheduler 最近运行状态</div></div><a class="badge" href="/cron">调度中心</a></div>
|
|
<div class="heartbeats" id="beats"></div>
|
|
</section>
|
|
<section class="panel">
|
|
<div class="panel-head"><div><div class="panel-title">策略交易</div><div class="panel-note">挂单、持仓、成交和平仓状态</div></div><a class="badge" href="/paper-trading">交易页</a></div>
|
|
<div class="trade-grid" id="tradeStats"></div>
|
|
<div class="timeline" id="recentOrders" style="margin-top:10px;max-height:250px"></div>
|
|
</section>
|
|
</div>
|
|
<section class="panel" style="margin-top:14px">
|
|
<div class="panel-head"><div><div class="panel-title">数据源新鲜度</div><div class="panel-note">市场、价格、舆情、链上和 AI 缓存是否还在更新</div></div></div>
|
|
<div class="source-grid" id="sources"></div>
|
|
</section>
|
|
</div>
|
|
<aside class="panel">
|
|
<div class="panel-head"><div><div class="panel-title">今天系统做了什么</div><div class="panel-note">最近任务、数据和交易动作时间线</div></div><span class="badge running">LIVE</span></div>
|
|
<div class="timeline" id="timeline"><div class="loading">加载中...</div></div>
|
|
</aside>
|
|
</main>
|
|
</div>
|
|
{% endblock %}
|
|
{% block extra_script %}
|
|
<script>
|
|
var API='';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()+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)}
|
|
function age(s){s=Number(s);if(!isFinite(s))return '--';if(s<60)return s+'s 前';if(s<3600)return Math.floor(s/60)+'m 前';if(s<86400)return Math.floor(s/3600)+'h 前';return Math.floor(s/86400)+'d 前'}
|
|
function badge(st){var cls=st||'';var text={ok:'正常',warn:'注意',danger:'异常',running:'运行中',off:'关闭'}[st]||st||'--';return '<span class="badge '+cls+'">'+esc(text)+'</span>'}
|
|
function num(v,d){return Number(v||0).toFixed(d==null?0:d)}function usd(v){v=Number(v||0);return (v>=0?'+':'-')+'$'+Math.abs(v).toFixed(2)}
|
|
function renderHero(d){var o=d.overall||{},s=d.summary||{};$('hero').innerHTML='<div class="health-card"><div class="status-orb '+esc(o.status||'ok')+'"></div><div><div class="health-label">'+esc(o.label||'运行正常')+'</div><div class="health-note">'+esc(o.summary||'--')+'</div></div></div>'+[
|
|
['启用任务',s.enabled_jobs||0,'运行中 '+(s.running_jobs||0)],
|
|
['数据源正常',s.data_sources_ok||0,'关键源新鲜'],
|
|
['活跃机会',s.active_opportunities||0,'确认窗口内样本'],
|
|
['挂单 / 持仓',(s.pending_orders||0)+' / '+(s.open_positions||0),'策略交易账本'],
|
|
['系统错误',s.system_errors||0,(s.latest_error&&(s.latest_error.display_message||s.latest_error.message))||'近窗口未处理错误']
|
|
].map(function(x){return '<div class="metric-card"><span>'+esc(x[0])+'</span><b class="'+(Number(x[1])>0&&x[0]==='系统错误'?'red-text':'')+'">'+esc(x[1])+'</b><small>'+esc(x[2])+'</small></div>'}).join('')}
|
|
function renderFunnel(items){$('funnel').innerHTML=(items||[]).map(function(x){return '<div class="flow-node '+esc(x.status||'')+'"><div class="dot"></div><div class="flow-label">'+esc(x.label)+'</div><div class="flow-value">'+esc(x.value||0)+'</div><div class="flow-sub">'+esc(x.sub||'--')+'</div></div>'}).join('')||'<div class="empty">暂无漏斗数据</div>'}
|
|
function renderBeats(items){$('beats').innerHTML=(items||[]).map(function(x){return '<div class="beat"><div class="beat-top"><div class="beat-name">'+esc(x.name)+'</div>'+badge(x.status)+'</div><div class="beat-meta">最近 '+age(x.age_seconds)+' · '+esc(x.runtime_status||'--')+'<br>下次 '+fmtTime(x.next_run_at)+' · '+(x.duration_ms?num(x.duration_ms/1000,1)+'s':'--')+'</div></div>'}).join('')||'<div class="empty">暂无任务</div>'}
|
|
function renderSources(items){$('sources').innerHTML=(items||[]).map(function(x){return '<div class="source"><div class="beat-top"><div class="source-name">'+esc(x.name)+'</div>'+badge(x.status)+'</div><div class="source-count">'+esc(x.count_24h||0)+'</div><div class="source-meta">最近 '+age(x.age_seconds)+'<br>累计 '+esc(x.total||0)+'</div></div>'}).join('')||'<div class="empty">暂无数据源</div>'}
|
|
function renderTrading(t){$('tradeStats').innerHTML=[['挂单',t.pending_orders||0,''],['持仓',t.open_positions||0,''],['成交',t.filled_orders_24h||0,'近窗口'],['取消',t.canceled_orders_24h||0,'近窗口'],['平仓',t.closed_trades_24h||0,'近窗口'],['已实现',usd(t.realized_pnl_24h),Number(t.realized_pnl_24h||0)>=0?'green-text':'red-text']].map(function(x){return '<div class="trade-stat"><span>'+esc(x[0])+'</span><b class="'+(x[2]&&x[2].indexOf('text')>0?x[2]:'')+'">'+esc(x[1])+'</b><small>'+esc(x[2]&&x[2].indexOf('text')<0?x[2]:'')+'</small></div>'}).join('');$('recentOrders').innerHTML=(t.recent_orders||[]).map(function(x){return '<div class="event"><div class="event-time">'+fmtTime(x.event_time)+'</div><div><div class="event-title">'+esc(x.symbol)+' · '+esc(x.side)+' · '+esc(x.status)+'</div><div class="event-detail">'+esc(x.cancel_reason||('目标 '+(x.target_price||'--')) )+'</div></div></div>'}).join('')||'<div class="empty">暂无订单动作</div>'}
|
|
function renderTimeline(items){$('timeline').innerHTML=(items||[]).map(function(x){return '<div class="event"><div class="event-time">'+fmtTime(x.time)+'</div><div><div class="event-title">'+esc(x.title)+'</div><div class="event-detail">'+badge(x.status)+' '+esc(x.detail||'')+'</div></div></div>'}).join('')||'<div class="empty">暂无系统动作</div>'}
|
|
function render(d){renderHero(d);$('generatedAt').textContent='更新 '+fmtTime(d.generated_at);renderFunnel(d.funnel);renderBeats(d.scheduler);renderSources(d.data_sources);renderTrading(d.trading||{});renderTimeline(d.timeline)}
|
|
async function loadOps(){try{var h=$('hoursSel').value;var r=await fetch(API+'/api/admin/operations-dashboard?hours='+h+'&_ts='+Date.now(),{cache:'no-store'});if(!r.ok)throw new Error('加载失败');render(await r.json())}catch(e){['hero','funnel','beats','sources','tradeStats','timeline','recentOrders'].forEach(function(id){$(id).innerHTML='<div class="empty">'+esc(e.message||'加载失败')+'</div>'})}}
|
|
loadOps();setInterval(loadOps,60000);
|
|
</script>
|
|
{% endblock %}
|