feat: 模块化命令框架 + runbook 严格断言与查询结果回传

- core/module: 命令模板、门控(feature flag/confirm/dry-run)、ecode 错误标签、ResultFormatter
- runbook executor: http.get/http.post/assert.json(expect_success 严格断言)、目标主机管理、任务落库、最终 stdout 直接回传
- cf 模块: /cf dns list 支持域名自动解析 zone_id;dns list/zones/workers list 格式化回传;API success:false 判真失败
- 全部 CF/CPA runbook 接入严格断言,杜绝假阳性 success
- 新增 cpa/cfdav/mail/sub 模块与 runbooks;QQ 订阅到期提醒调度
- Web 后台: 模块设置(CF/CPA/cfdav/AI)、OPS 任务、渠道管理、审计页
This commit is contained in:
2026-09-13 07:42:17 +08:00
parent 9085c490f3
commit 81939825d1
86 changed files with 6885 additions and 467 deletions
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模块开关 - Ops-Assistant</title>
<style>
:root{--bg:#f5f7fb;--text:#222;--card:#fff;--border:#e5e7eb;--muted:#6b7280;--accent:#ee5a24;--accent-hover:#d63031;--ok:#16a34a;--off:#64748b;}
[data-theme="dark"]{--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;--accent:#f97316;--accent-hover:#ea580c;--ok:#22c55e;--off:#94a3b8;}
*{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:linear-gradient(135deg,#ff6b6b,#ee5a24);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 .right{display:flex;align-items:center;gap:8px}
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;}
.header button{width:28px;padding:0;transition:transform .2s ease}
.header button.spin{transform:rotate(180deg)}
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
.wrap{max-width:980px;margin:0 auto;padding:14px}
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
.table{width:100%;border-collapse:collapse;font-size:13px}
.table th,.table td{border-bottom:1px solid var(--border);padding:10px;text-align:left}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
.btn{border:none;border-radius:6px;padding:6px 10px;cursor:pointer;color:#fff;background:var(--accent);font-size:12px}
.btn.secondary{background:#6b7280}
.btn.danger{background:#9b1c1c}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
input{padding:6px 8px;border:1px solid var(--border);border-radius:6px;font-size:12px;background:var(--card);color:var(--text)}
small{color:var(--muted)}
</style>
</head>
<body>
<div class="header">
<div>⚙️ 模块开关 · {{.version}}</div>
<div class="right">
<button id="themeToggle" 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="card">
<div class="row" style="justify-content:space-between;">
<div><strong>模块列表</strong> <small>(需要填写变更原因)</small></div>
<div class="row">
<input id="reason" placeholder="请输入变更原因" style="min-width:240px" />
<button class="btn secondary" onclick="load()">刷新</button>
</div>
</div>
<div id="msg" style="margin:8px 0;color:var(--muted)"></div>
<table class="table">
<thead><tr><th>模块</th><th>开关</th><th>Flag Key</th><th>操作</th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
<script>
let theme='dark';
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
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||{};
}
async function load(){
try{
const data=await api('/api/v1/modules');
const rows=(data.modules||[]).map(m=>{
const badge=m.enabled?'on':'off';
const label=m.enabled?'启用':'禁用';
const next=!m.enabled;
const btnClass=m.enabled?'danger':'btn';
return `<tr>
<td>${esc(m.display_name||m.module)}</td>
<td><span class="badge ${badge}">${label}</span></td>
<td><small>${esc(m.flag_key||'')}</small></td>
<td><button class="btn ${btnClass}" onclick="toggle('${esc(m.module)}',${next})">${next?'启用':'禁用'}</button></td>
</tr>`;
}).join('');
document.getElementById('rows').innerHTML=rows || '<tr><td colspan="4">暂无模块</td></tr>';
document.getElementById('msg').textContent='';
}catch(e){
document.getElementById('msg').textContent='加载失败:'+(e.message||e);
}
}
async function toggle(module, enabled){
const reason=(document.getElementById('reason').value||'').trim();
if(!reason){alert('请填写变更原因');return;}
try{
await api('/api/v1/modules/'+module+'/toggle',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled,reason})
});
await load();
}catch(e){alert('操作失败:'+(e.message||e));}
}
function setThemeIcon(theme){
const icon=document.getElementById('themeIcon');
if(!icon) return;
if(theme==='dark'){
icon.innerHTML='<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
}else{
icon.innerHTML='<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>';
}
}
function applyTheme(t){
theme=t; if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
else{document.documentElement.removeAttribute('data-theme');}
localStorage.setItem('theme',theme); setThemeIcon(theme);
}
function toggleTheme(){
const next=theme==='light'?'dark':'light';
const btn=document.getElementById('themeToggle');
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
applyTheme(next);
}
(function(){
const saved=localStorage.getItem('theme')||'dark';
applyTheme(saved);
const btn=document.getElementById('themeToggle');
if(btn){ btn.addEventListener('click',toggleTheme); }
load();
})();
</script>
</body>
</html>