Files
ops-assistant/templates/ops.html
2026-03-19 21:23:28 +08:00

230 lines
10 KiB
HTML
Raw Permalink 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.
<!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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>