230 lines
10 KiB
HTML
230 lines
10 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>🛠️ OPS任务 - Ops-Assistant</title>
|
||
<style>
|
||
:root{
|
||
--bg:#f5f7fb;
|
||
--text:#222;
|
||
--card:#fff;
|
||
--border:#e5e7eb;
|
||
--muted:#6b7280;
|
||
--accent:#ee5a24;
|
||
--accent-hover:#d63031;
|
||
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||
}
|
||
[data-theme="dark"]{
|
||
--bg:#0f172a;
|
||
--text:#e5e7eb;
|
||
--card:#111827;
|
||
--border:#1f2937;
|
||
--muted:#9ca3af;
|
||
--accent:#f97316;
|
||
--accent-hover:#ea580c;
|
||
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||
}
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer}
|
||
.header button{display:inline-flex;align-items:center;justify-content:center}
|
||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||
.wrap{max-width:1000px;margin:0 auto;padding:14px}
|
||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
||
input,select{padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||
button.secondary{background:#6b7280}
|
||
button.danger{background:#9b1c1c}
|
||
.card{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||
.row{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap}
|
||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||
.pending{background:#fef3c7;color:#92400e}.running{background:#dbeafe;color:#1e3a8a}.success{background:#dcfce7;color:#166534}.failed{background:#fee2e2;color:#991b1b}.cancelled{background:#e5e7eb;color:#374151}
|
||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);word-break:break-all}
|
||
pre{background:#0b1020;color:#d1d5db;border-radius:6px;padding:8px;overflow:auto;font-size:12px;max-height:220px}
|
||
.small{font-size:12px;color:var(--muted)}
|
||
.empty{padding:24px;text-align:center;color:var(--muted)}
|
||
.theme-hidden{display:none !important;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<div>🛠️ OPS任务面板 · {{.version}}</div>
|
||
<div>
|
||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||
<circle cx="12" cy="12" r="5"></circle>
|
||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||
</svg>
|
||
</button>
|
||
<a href="/">返回首页</a>
|
||
<a href="/logout">退出</a>
|
||
</div>
|
||
</div>
|
||
<div class="wrap">
|
||
<div class="toolbar">
|
||
<select id="qStatus">
|
||
<option value="">全部状态</option>
|
||
<option value="pending">pending</option>
|
||
<option value="running">running</option>
|
||
<option value="success">success</option>
|
||
<option value="failed">failed</option>
|
||
<option value="cancelled">cancelled</option>
|
||
</select>
|
||
<input id="qTarget" placeholder="target (如 hwsg)">
|
||
<input id="qRunbook" placeholder="runbook (如 cpa_usage_backup)">
|
||
<input id="qRequest" placeholder="request_id">
|
||
<input id="qOperator" placeholder="operator (user_id)">
|
||
<input id="qFrom" placeholder="from (RFC3339)">
|
||
<input id="qTo" placeholder="to (RFC3339)">
|
||
<button onclick="loadJobs()">查询</button>
|
||
<button class="secondary" onclick="resetFilter()">重置</button>
|
||
</div>
|
||
<div id="jobs"></div>
|
||
</div>
|
||
<script>
|
||
let me=null;
|
||
let pollTimer=null;
|
||
|
||
async function api(url,opt={}){
|
||
const r=await fetch(url,opt);
|
||
const out=await r.json().catch(()=>({}));
|
||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||
return out?.data||{};
|
||
}
|
||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
|
||
function statusTag(s){const k=(s||'pending').toLowerCase();return `<span class="badge ${k}">${esc(k)}</span>`;}
|
||
|
||
async function loadMe(){
|
||
const r=await fetch('/api/v1/me');
|
||
const out=await r.json().catch(()=>({}));
|
||
if(!r.ok) throw new Error(out.message||'读取用户失败');
|
||
me=out?.data||{};
|
||
}
|
||
|
||
function buildQuery(){
|
||
const q={
|
||
status:document.getElementById('qStatus').value.trim(),
|
||
target:document.getElementById('qTarget').value.trim(),
|
||
runbook:document.getElementById('qRunbook').value.trim(),
|
||
request_id:document.getElementById('qRequest').value.trim(),
|
||
operator:document.getElementById('qOperator').value.trim(),
|
||
from:document.getElementById('qFrom').value.trim(),
|
||
to:document.getElementById('qTo').value.trim(),
|
||
limit:'50'
|
||
};
|
||
const qs=Object.entries(q).filter(([,v])=>v).map(([k,v])=>k+'='+encodeURIComponent(v)).join('&');
|
||
return '/api/v1/ops/jobs'+(qs?'?'+qs:'');
|
||
}
|
||
|
||
async function loadJobs(){
|
||
const box=document.getElementById('jobs');
|
||
box.innerHTML='<div class="card">加载中...</div>';
|
||
try{
|
||
const data=await api(buildQuery());
|
||
const jobs=Array.isArray(data.jobs)?data.jobs:[];
|
||
if(!jobs.length){box.innerHTML='<div class="card empty">暂无任务</div>';return;}
|
||
box.innerHTML=jobs.map(j=>`<div class="card">
|
||
<div class="row"><strong>#${j.id} ${esc(j.command||'')}</strong>${statusTag(j.status)}</div>
|
||
<div class="small">runbook=${esc(j.runbook||'-')} · target=${esc(j.target||'-')} · risk=${esc(j.risk_level||'-')}</div>
|
||
<div class="mono">request=${esc(j.request_id||'-')} · start=${esc(j.started_at||'-')} · end=${esc(j.ended_at||'-')}</div>
|
||
<div class="toolbar" style="margin-top:8px;">
|
||
<button class="secondary" onclick="viewDetail(${j.id})">查看步骤</button>
|
||
${can('can_cancel_ops')?`<button class="danger" onclick="cancelJob(${j.id},'${esc(j.status||'')}')">取消</button>`:''}
|
||
${can('can_retry_ops')?`<button onclick="retryJob(${j.id},'${esc(j.status||'')}')">重试</button>`:''}
|
||
</div>
|
||
<div id="detail-${j.id}" class="small"></div>
|
||
</div>`).join('');
|
||
}catch(e){box.innerHTML='<div class="card">加载失败:'+esc(e.message||e)+'</div>';}
|
||
schedulePoll();
|
||
}
|
||
|
||
function schedulePoll(){
|
||
if(pollTimer) clearTimeout(pollTimer);
|
||
pollTimer=setTimeout(async ()=>{
|
||
try{ await loadJobs(); }catch(e){}
|
||
},8000);
|
||
}
|
||
|
||
async function viewDetail(id){
|
||
const el=document.getElementById('detail-'+id);
|
||
el.textContent='加载步骤中...';
|
||
try{
|
||
const d=await api('/api/v1/ops/jobs/'+id);
|
||
const steps=Array.isArray(d.steps)?d.steps:[];
|
||
const stats=d.step_stats||{};
|
||
const total=d.step_total||steps.length||0;
|
||
const dur=d.duration||{};
|
||
const head=`<div class="small" style="margin:6px 0;">steps=${total} · running=${stats.running||0} · success=${stats.success||0} · failed=${stats.failed||0} · job_ms=${dur.job_ms||0}</div>`;
|
||
if(!steps.length){el.innerHTML=head+'无步骤';return;}
|
||
el.innerHTML=head+steps.map(s=>`<div style="margin-top:6px;padding:6px;border:1px solid #eee;border-radius:8px;">
|
||
<div><strong>${esc(s.step_id)}</strong> (${esc(s.action)}) ${statusTag(s.status)}</div>
|
||
<div class="mono">rc=${s.rc} · ${esc(s.started_at||'')} -> ${esc(s.ended_at||'')}</div>
|
||
<details><summary>stdout/stderr</summary><pre>${esc((s.stdout_tail||'')+'\n---\n'+(s.stderr_tail||''))}</pre></details>
|
||
</div>`).join('');
|
||
}catch(e){el.textContent='加载失败:'+(e.message||e);}
|
||
}
|
||
|
||
async function cancelJob(id,status){
|
||
if(!['pending','running'].includes(String(status||'').toLowerCase())){alert('仅 pending/running 可取消');return;}
|
||
const reason=prompt('请输入取消原因(必填)')||'';
|
||
if(!reason.trim()){alert('取消原因不能为空');return;}
|
||
if(!confirm('确认取消任务 #'+id+' ?')) return;
|
||
try{await api('/api/v1/ops/jobs/'+id+'/cancel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason})});alert('已取消');loadJobs();}
|
||
catch(e){alert('取消失败:'+(e.message||e));}
|
||
}
|
||
|
||
async function retryJob(id,status){
|
||
if(String(status||'').toLowerCase()!=='failed'){alert('仅 failed 可重试');return;}
|
||
const reason=prompt('请输入重试原因(必填)')||'';
|
||
if(!reason.trim()){alert('重试原因不能为空');return;}
|
||
try{const d=await api('/api/v1/ops/jobs/'+id+'/retry',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason})});alert('已重试,新任务ID='+d.new_job_id);loadJobs();}
|
||
catch(e){alert('重试失败:'+(e.message||e));}
|
||
}
|
||
|
||
function resetFilter(){
|
||
document.getElementById('qStatus').value='';
|
||
document.getElementById('qTarget').value='';
|
||
document.getElementById('qRunbook').value='';
|
||
document.getElementById('qRequest').value='';
|
||
document.getElementById('qOperator').value='';
|
||
document.getElementById('qFrom').value='';
|
||
document.getElementById('qTo').value='';
|
||
loadJobs();
|
||
}
|
||
|
||
function applyTheme(theme){
|
||
if(theme==='dark'){
|
||
document.documentElement.setAttribute('data-theme','dark');
|
||
}else{
|
||
document.documentElement.removeAttribute('data-theme');
|
||
}
|
||
localStorage.setItem('theme',theme);
|
||
}
|
||
function toggleTheme(){
|
||
const cur=localStorage.getItem('theme')||'dark';
|
||
applyTheme(cur==='light'?'dark':'light');
|
||
}
|
||
function initTheme(){
|
||
const saved=localStorage.getItem('theme')||'dark';
|
||
applyTheme(saved);
|
||
const btn=document.getElementById('themeToggle');
|
||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
||
}
|
||
|
||
(async function(){
|
||
try{initTheme();await loadMe();await loadJobs();}catch(e){document.getElementById('jobs').innerHTML='<div class="card">初始化失败:'+esc(e.message||e)+'</div>';}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|