init: ops-assistant codebase

This commit is contained in:
OpenClaw Agent
2026-03-19 21:23:28 +08:00
commit 81deba4766
94 changed files with 10767 additions and 0 deletions

145
templates/ai_settings.html Normal file
View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 配置 - 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 .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}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
.row label{width:180px;color:var(--muted);font-size:13px}
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
button.secondary{background:#6b7280}
small{color:var(--muted)}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>🤖 AI 配置 · {{.version}}</div>
<div class="right">
<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="card">
<h3>AI 模型配置</h3>
<div class="row"><label>启用 AI 翻译</label><input id="enabled" type="checkbox"></div>
<div class="row"><label>Base URL</label><input id="base_url" placeholder="https://api.xxx/v1"></div>
<div class="row"><label>API Key</label><input id="api_key" type="password" placeholder="sk-..." autocomplete="new-password"></div>
<div class="row"><label>Model</label><input id="model" placeholder="gemini-3-flash-preview"></div>
<div class="row"><label>Timeout (秒)</label><input id="timeout" placeholder="15"></div>
<small>用于将“非命令文本”翻译为标准命令(仅翻译,不自动执行)。</small>
<div class="btns">
<button onclick="save()">保存</button>
<button class="secondary" onclick="load()">刷新</button>
</div>
<small id="msg"></small>
</div>
</div>
<script>
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]));}
async function load(){
try{
const d=await api('/api/v1/admin/ai/settings');
const s=d.settings||{};
document.getElementById('enabled').checked = (String(s.ai_enabled||'').toLowerCase()==='true');
document.getElementById('base_url').value = s.ai_base_url || '';
document.getElementById('api_key').value = s.ai_api_key || '';
document.getElementById('model').value = s.ai_model || '';
document.getElementById('timeout').value = s.ai_timeout_seconds || '15';
document.getElementById('msg').textContent='已加载';
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
}
async function save(){
const payload={
enabled: document.getElementById('enabled').checked,
base_url: document.getElementById('base_url').value.trim(),
api_key: document.getElementById('api_key').value.trim(),
model: document.getElementById('model').value.trim(),
timeout_seconds: parseInt(document.getElementById('timeout').value||'15',10)
};
try{
await api('/api/v1/admin/ai/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
document.getElementById('msg').textContent='已保存';
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(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(theme){
if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
else{document.documentElement.removeAttribute('data-theme');}
localStorage.setItem('theme',theme);
setThemeIcon(theme);
}
function toggleTheme(){
const cur=localStorage.getItem('theme')||'dark';
const next = cur==='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>

211
templates/audit.html Normal file
View File

@@ -0,0 +1,211 @@
<!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;
--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 .title{font-weight:700}
.header .sub{font-size:12px;opacity:.9}
.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}
.header a:hover{background:rgba(255,255,255,.35)}
.wrap{max-width:760px;margin:0 auto;padding:14px}
.filters{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
.filters input,.filters select{width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
small{color:var(--muted)}
.actions{margin:0 0 10px;display:flex;gap:8px;flex-wrap:wrap}
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
button:hover{background:var(--accent-hover)}
button.secondary{background:#6b7280}
button.secondary:hover{background:#4b5563}
.list{display:flex;flex-direction:column;gap:10px}
.log-card{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none}
.row{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}
.tag{display:inline-block;padding:2px 8px;border-radius:6px;font-size:12px}
.tag.success{background:#dcfce7;color:#166534}
.tag.failed{background:#fee2e2;color:#991b1b}
.tag.denied{background:#fef3c7;color:#92400e}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);word-break:break-all}
.note{font-size:13px;color:var(--text);margin-top:6px;white-space:pre-wrap;word-break:break-word}
.empty{text-align:center;padding:40px 10px;color:var(--muted)}
.hidden{display:none !important;}
@media(max-width:640px){
.header{padding:14px 12px 10px;align-items:flex-start;flex-direction:column}
.header>div:last-child{display:flex;gap:8px}
.wrap{padding:12px}
.filters{grid-template-columns:1fr}
}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>
<div class="title">🧾 审计日志</div>
<div class="sub">{{.version}}</div>
</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 id="btnChannels" href="/channels">渠道配置</a>
<a href="/logout">退出</a>
</div>
</div>
<div class="wrap">
<div class="filters">
<div><small>操作类型</small><input id="fAction" placeholder="如record.delete.self"></div>
<div><small>目标类型</small><input id="fTarget" placeholder="如transaction"></div>
<div><small>结果</small><select id="fResult"><option value="">全部</option><option value="success">成功</option><option value="denied">拒绝</option><option value="failed">失败</option></select></div>
<div><small>操作人ID</small><input id="fActor" placeholder="如1"></div>
<div><small>开始时间RFC3339</small><input id="fFrom" placeholder="2026-03-09T00:00:00+08:00"></div>
<div><small>结束时间RFC3339</small><input id="fTo" placeholder="2026-03-10T00:00:00+08:00"></div>
</div>
<div class="actions">
<button onclick="loadAudit()">查询</button>
<button class="secondary" onclick="resetFilters()">重置</button>
</div>
<div id="list" class="list"></div>
</div>
<script>
let me=null;
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function qs(id){return document.getElementById(id).value.trim();}
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 can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
const actionMap={
'auth.login.success':'登录成功','auth.login.failed':'登录失败','auth.logout':'退出登录',
'record.delete.self':'删除本人记录','record.delete.all':'删除全员记录','record.export':'导出记录',
'flag.update':'修改高级开关',
'channel_update_draft':'更新渠道草稿','channel_publish':'发布渠道草稿','channel_reload':'热加载渠道',
'channel_disable_all':'一键关闭全部渠道','channel_enable':'启用渠道','channel_disable':'停用渠道','channel_test':'测试渠道连接'
};
const targetMap={
'transaction':'记账记录','feature_flag':'高级开关','channel':'渠道','user':'用户','system':'系统'
};
function actionLabel(v){return actionMap[v]||v||'-';}
function targetLabel(v){return targetMap[v]||v||'-';}
function parseResult(note){
const m=String(note||'').match(/result=(success|failed|denied)/);
return m?m[1]:'';
}
function resultLabel(r){
if(r==='success') return '成功';
if(r==='failed') return '失败';
if(r==='denied') return '拒绝';
return '未标注';
}
function resetFilters(){['fAction','fTarget','fResult','fActor','fFrom','fTo'].forEach(id=>document.getElementById(id).value='');loadAudit();}
async function loadAudit(){
const p=new URLSearchParams();
const m={action:qs('fAction'),target_type:qs('fTarget'),result:qs('fResult'),actor_id:qs('fActor'),from:qs('fFrom'),to:qs('fTo')};
Object.entries(m).forEach(([k,v])=>{if(v)p.set(k,v)});
p.set('limit','200');
const listEl=document.getElementById('list');
listEl.innerHTML='<div class="empty">加载中...</div>';
try{
const out=await api('/api/v1/admin/audit?'+p.toString());
const list=Array.isArray(out.logs)?out.logs:[];
if(!list.length){listEl.innerHTML='<div class="empty">暂无审计记录</div>';return;}
listEl.innerHTML=list.map(it=>{
const result=parseResult(it.note);
const resultClass=result||'success';
const note=String(it.note||'').replace(/\s*\|\s*result=(success|failed|denied)\s*$/,'').trim();
return `<div class="log-card">
<div class="row"><strong>${actionLabel(it.action)}</strong><span class="tag ${resultClass}">${resultLabel(result)}</span></div>
<div class="mono" style="margin-top:4px;">#${it.id} · ${esc(it.created_at||'')}</div>
<div class="mono" style="margin-top:2px;">操作人: ${it.actor_id} · 目标: ${targetLabel(it.target_type)} (${esc(it.target_id||'')})</div>
<div class="note">${esc(note||'(无备注)')}</div>
</div>`;
}).join('');
}catch(e){
listEl.innerHTML='<div class="empty">加载失败:'+esc(e.message||e)+'</div>';
}
}
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 initPermissionUI(){
document.getElementById('btnChannels').classList.toggle('hidden',!can('can_view_channels'));
}
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(); initPermissionUI(); await loadAudit(); }
catch(e){ document.getElementById('list').innerHTML='<div class="empty">初始化失败:'+esc(e.message||e)+'</div>'; }
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudflare 配置 - Ops-Assistant</title>
<style>
:root{
--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 .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}
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
.wrap{max-width:760px;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}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
.row label{width:180px;color:var(--muted);font-size:13px}
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:#0b1220;color:var(--text)}
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
button.secondary{background:#6b7280}
small{color:var(--muted)}
</style>
</head>
<body>
<div class="header">
<div>☁️ Cloudflare 配置</div>
<div class="right">
<a href="/">返回首页</a>
<a href="/logout">退出</a>
</div>
</div>
<div class="wrap">
<div class="card">
<h3>账号凭据</h3>
<div class="row"><label>Account ID</label><input id="account" placeholder="请输入 Account ID"></div>
<div class="row"><label>API Token</label><input id="token" placeholder="请输入 API Token"></div>
<small>用于查询/修改 DNS & Workers单账号</small>
<div class="btns">
<button onclick="save()">保存</button>
<button class="secondary" onclick="load()">刷新</button>
</div>
<small id="msg"></small>
</div>
</div>
<script>
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]));}
async function load(){
try{
const d=await api('/api/v1/admin/cf/settings');
document.getElementById('account').value = d.settings?.cf_account_id || '';
document.getElementById('token').value = d.settings?.cf_api_token || '';
document.getElementById('msg').textContent='已加载';
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
}
async function save(){
const payload={
account_id: document.getElementById('account').value.trim(),
api_token: document.getElementById('token').value.trim()
};
try{
await api('/api/v1/admin/cf/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
document.getElementById('msg').textContent='已保存';
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
}
load();
</script>
</body>
</html>

413
templates/channels.html Normal file
View File

@@ -0,0 +1,413 @@
<!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;
--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}
.header a:hover { background: rgba(255,255,255,.35); }
.wrap { max-width: 760px; margin: 0 auto; padding: 14px; }
.toolbar { display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
.tip { color:var(--muted); font-size:12px; margin-bottom:10px; }
.card { background:var(--card); border-radius:6px; padding:14px; margin-bottom:10px; border:1px solid var(--border); box-shadow:none; }
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
.row label { width:140px; color:var(--muted); font-size:13px; }
.row input, .row textarea { flex:1; min-width:220px; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:13px; background:var(--card); color:var(--text); }
.row textarea { min-height:74px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.btns { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; }
button { border:none; border-radius:6px; padding:8px 12px; cursor:pointer; color:#fff; font-size:13px; }
button:disabled { opacity:.65; cursor:not-allowed; }
.btn-apply, .btn-save, .btn-publish, .btn-test, .btn-reload, .btn-enable { background:var(--accent); }
.btn-apply:hover, .btn-save:hover, .btn-publish:hover, .btn-test:hover, .btn-reload:hover, .btn-enable:hover { background:var(--accent-hover); }
.btn-disable { background:#9b2c2c; }
.btn-disable:hover { background:#7f1d1d; }
.btn-ghost { background:transparent; color:var(--muted); border:1px solid var(--border); }
.btn-ghost:hover { background:rgba(0,0,0,.04); }
.badge { display:inline-block; font-size:12px; border-radius:6px; padding:2px 8px; }
.state { display:inline-block; font-size:12px; border-radius:6px; padding:2px 8px; margin-left:6px; }
.ok { background:#dcfce7; color:#166534; }
.error { background:#fee2e2; color:#991b1b; }
.disabled { background:#e5e7eb; color:#374151; }
small { color:var(--muted); }
.hidden{display:none !important;}
.advanced{border-top:1px dashed var(--border); margin-top:8px; padding-top:8px;}
@media(max-width:640px){
.header { padding: 14px 12px 10px; align-items:flex-start; flex-direction:column; }
.header > div:last-child { display:flex; gap:8px; }
.wrap { padding:12px; }
.toolbar button { flex: 1 1 calc(50% - 4px); }
.row label { width:100%; }
.row input, .row textarea { min-width:100%; }
.btns button { flex: 1 1 calc(50% - 4px); }
}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>🦞 渠道配置中心(草稿/发布) · {{.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">
<button id="btnDisableAll" class="btn-disable" onclick="disableAll()">一键全部关闭</button>
<button id="btnReload" class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
</div>
<div class="tip">填写需要的参数即可(每项一个输入框)。高级 JSON 已折叠,默认不需要碰。</div>
<div id="app"></div>
</div>
<script>
let me=null;
const app = document.getElementById('app');
async function api(url, options = {}) {
const r = await fetch(url, options);
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 renderError(msg) {
app.innerHTML = `<div class="card" style="border:1px solid #fecaca;background:#fef2f2;color:#991b1b;">${msg}</div>`;
}
function pretty(objStr) {
try { return JSON.stringify(JSON.parse(objStr || '{}'), null, 2); } catch { return '{}'; }
}
function statusBadge(status) {
const s = (status || 'disabled');
const cls = s === 'ok' ? 'ok' : (s === 'error' ? 'error' : 'disabled');
return `<span class="badge ${cls}">${s}</span>`;
}
function runtimeState(ch) {
if (!ch.enabled) return '<span class="state disabled">已关闭</span>';
if ((ch.status || '').toLowerCase() === 'ok') return '<span class="state ok">运行中</span>';
if ((ch.status || '').toLowerCase() === 'error') return '<span class="state error">配置异常</span>';
return '<span class="state disabled">待检测</span>';
}
function parseJSONSafe(text) {
try { return JSON.parse(text || '{}'); } catch { return null; }
}
function getFieldDefs(platform){
if(platform==='telegram'){
return [
{key:'token', label:'Telegram Bot Token'}
];
}
if(platform==='qqbot_official'){
return [
{key:'appid', label:'QQ Bot AppID'},
{key:'secret', label:'QQ Bot Secret'}
];
}
if(platform==='feishu'){
return [
{key:'app_id', label:'飞书 AppID'},
{key:'app_secret', label:'飞书 AppSecret'},
{key:'verification_token', label:'飞书 VerificationToken可选'},
{key:'encrypt_key', label:'飞书 EncryptKey可选'}
];
}
return [];
}
async function fetchChannels() {
const out = await api('/api/v1/admin/channels');
const data = out?.channels;
if (!Array.isArray(data)) throw new Error('渠道返回格式异常');
return data;
}
function render(channels) {
app.innerHTML = channels.map(ch => {
const draftCfg = pretty(ch.draft_config_json || ch.config_json);
const draftSec = pretty(ch.draft_secrets || ch.secrets);
const secObj = parseJSONSafe(draftSec) || {};
const hasDraft = ch.has_draft ? '<span class="badge" style="background:#fef3c7;color:#92400e">draft</span>' : '';
const fields = getFieldDefs(ch.platform);
const fieldsHtml = fields.map(f => {
let v = secObj[f.key] || '';
if (String(v).trim() === '***') { v = ''; }
return `<div class="row"><label>${esc(f.label)}</label><input class="field" data-key="${esc(f.key)}" value="${esc(v)}" placeholder="留空表示不修改"></div>`;
}).join('');
return `<div class="card" data-platform="${esc(ch.platform)}">
<h3>${esc(ch.name || ch.platform)} ${statusBadge(ch.status)} ${runtimeState(ch)} ${hasDraft}</h3>
<small>平台:${esc(ch.platform)} 发布:${esc(ch.published_at || '-')} 最近检测:${esc(ch.last_check_at || '-')}</small>
<div class="row"><label>启用</label><input type="checkbox" class="enabled" ${ch.enabled ? 'checked' : ''}></div>
<div class="row"><label>显示名称</label><input class="name" value="${esc(ch.name||'')}"></div>
${fieldsHtml}
<div class="btns">
<button class="btn-apply" onclick="applyNow('${esc(ch.platform)}')">保存并立即生效</button>
<button class="btn-save" onclick="saveDraft('${esc(ch.platform)}')">保存草稿</button>
<button class="btn-publish" onclick="publishDraft('${esc(ch.platform)}')">发布草稿</button>
<button class="btn-test" onclick="testConn('${esc(ch.platform)}')">测试连接</button>
${ch.enabled ? `<button class="btn-disable" onclick="toggleChannel('${esc(ch.platform)}', false)">关闭通道</button>` : `<button class="btn-enable" onclick="toggleChannel('${esc(ch.platform)}', true)">开启通道</button>`}
<button class="btn-ghost" onclick="toggleAdvanced('${esc(ch.platform)}')">高级 JSON</button>
</div>
<div class="advanced hidden">
<div class="row"><label>配置 JSON</label><textarea class="config">${draftCfg}</textarea></div>
<div class="row"><label>密钥 JSON</label><textarea class="secrets">${draftSec}</textarea></div>
</div>
<small class="msg"></small>
</div>`;
}).join('');
}
function collectChannelForm(platform) {
const card = document.querySelector(`[data-platform="${platform}"]`);
const name = card.querySelector('.name').value.trim();
const enabled = card.querySelector('.enabled').checked;
const cfgText = card.querySelector('.config') ? card.querySelector('.config').value : '{}';
const secText = card.querySelector('.secrets') ? card.querySelector('.secrets').value : '{}';
const msg = card.querySelector('.msg');
const config = parseJSONSafe(cfgText) || {};
const secrets = parseJSONSafe(secText) || {};
if (config === null || secrets === null) {
msg.textContent = 'JSON 格式错误,请检查高级 JSON';
return null;
}
// 覆盖结构化字段,但保留未知字段
let hasSecretChange = false;
card.querySelectorAll('.field').forEach(input => {
const key = input.getAttribute('data-key');
const val = (input.value || '').trim();
if (val !== '') {
secrets[key] = val;
hasSecretChange = true;
}
});
const payload = { name, enabled, config };
if (hasSecretChange) payload.secrets = secrets;
return { card, msg, payload };
}
function setCardBusy(card, busy) {
if (!card) return;
card.querySelectorAll('button').forEach(btn => { btn.disabled = busy; });
}
async function applyNow(platform) {
const f = collectChannelForm(platform);
if (!f) return;
const { card, msg, payload } = f;
const applyBtn = card.querySelector('.btn-apply');
const oldText = applyBtn ? applyBtn.textContent : '';
setCardBusy(card, true);
if (applyBtn) applyBtn.textContent = '生效中...';
try {
msg.textContent = '保存并生效中...';
await api('/api/v1/admin/channels/' + platform + '/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
msg.textContent = '已生效';
await reload();
} catch (e) {
msg.textContent = '失败:' + (e && e.message ? e.message : e);
} finally {
setCardBusy(card, false);
if (applyBtn) applyBtn.textContent = oldText || '保存并立即生效';
}
}
async function saveDraft(platform) {
const f = collectChannelForm(platform);
if (!f) return;
const { msg, payload } = f;
try {
await api('/api/v1/admin/channels/' + platform, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
msg.textContent = '草稿已保存';
await reload();
} catch (e) {
msg.textContent = '保存失败:' + (e && e.message ? e.message : e);
}
}
async function publishDraft(platform) {
const msg = msgOf(platform);
try {
await api('/api/v1/admin/channels/' + platform + '/publish', { method: 'POST' });
msg.textContent = '发布成功,建议点“热加载运行参数”';
await reload();
} catch (e) {
msg.textContent = '发布失败:' + (e && e.message ? e.message : e);
}
}
async function testConn(platform) {
const msg = msgOf(platform);
try {
msg.textContent = '正在测试...';
const out = await api('/api/v1/admin/channels/' + platform + '/test', { method: 'POST' });
msg.textContent = `测试结果:${out.status || 'unknown'} ${out.detail ? ' / ' + out.detail : ''}`;
await reload();
} catch (e) {
msg.textContent = '测试失败:' + (e && e.message ? e.message : e);
}
}
async function toggleChannel(platform, enable) {
const msg = msgOf(platform);
try {
msg.textContent = enable ? '正在开启...' : '正在关闭...';
await api('/api/v1/admin/channels/' + platform + (enable ? '/enable' : '/disable'), { method: 'POST' });
msg.textContent = enable ? '已开启(请点热加载生效)' : '已关闭(请点热加载生效)';
await reload();
} catch (e) {
msg.textContent = (enable ? '开启失败:' : '关闭失败:') + (e && e.message ? e.message : e);
}
}
async function reloadRuntime() {
try {
const out = await api('/api/v1/admin/channels/reload', { method: 'POST' });
alert('热加载成功:' + (out.detail || 'ok'));
await reload();
} catch (e) {
alert('热加载失败:' + (e && e.message ? e.message : e));
}
}
async function disableAll() {
if (!confirm('确认要关闭所有通道吗?')) return;
try {
const out = await api('/api/v1/admin/channels/disable-all', { method: 'POST' });
alert('已关闭通道数:' + (out.affected || 0) + ',请点热加载生效。');
await reload();
} catch (e) {
alert('批量关闭失败:' + (e && e.message ? e.message : e));
}
}
function msgOf(platform) {
return document.querySelector(`[data-platform="${platform}"] .msg`);
}
function toggleAdvanced(platform){
const card = document.querySelector(`[data-platform="${platform}"]`);
if(!card) return;
const adv = card.querySelector('.advanced');
if(adv) adv.classList.toggle('hidden');
}
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 initPermissionUI(){
document.getElementById('btnReload').classList.toggle('hidden',!can('can_edit_channels'));
document.getElementById('btnDisableAll').classList.toggle('hidden',!can('can_edit_channels'));
}
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 reload() {
try {
const channels = await fetchChannels();
render(channels);
} catch (e) {
console.error(e);
renderError('页面加载失败:' + (e && e.message ? e.message : e));
}
}
window.addEventListener('error', (e) => {
renderError('前端脚本异常:' + (e && e.message ? e.message : 'unknown'));
});
window.addEventListener('unhandledrejection', (e) => {
const msg = e && e.reason && e.reason.message ? e.reason.message : String(e.reason || 'unknown');
renderError('前端请求异常:' + msg);
});
(async function(){
try{ initTheme(); await loadMe(); initPermissionUI(); await reload(); }
catch(e){ renderError('初始化失败:'+(e.message||e)); }
})();
</script>
</body>
</html>

207
templates/cpa_settings.html Normal file
View File

@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CPA 配置 - 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 .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}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
.row label{width:180px;color:var(--muted);font-size:13px}
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
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}
.table{width:100%;border-collapse:collapse;font-size:13px}
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;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}
small{color:var(--muted)}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>🔧 CPA 配置 · {{.version}}</div>
<div class="right">
<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="card">
<h3>CPA 管理接口</h3>
<div class="row"><label>CPA Management Base</label><input id="base" placeholder="https://cpa.pao.xx.kg/v0/management"></div>
<div class="row"><label>CPA Management Token</label><input id="token" placeholder="请输入 Token"></div>
<small>用于访问 CPA 管理接口Bearer Token</small>
<div class="btns">
<button onclick="save()">保存</button>
<button class="secondary" onclick="load()">刷新</button>
</div>
<small id="msg"></small>
</div>
<div class="card">
<h3>目标主机Ops Targets</h3>
<div class="row"><label>Name</label><input id="name" placeholder="如 hwsg"></div>
<div class="row"><label>Host</label><input id="host" placeholder="如 124.243.132.158"></div>
<div class="row"><label>Port</label><input id="port" placeholder="22"></div>
<div class="row"><label>User</label><input id="user" placeholder="root"></div>
<div class="row"><label>Enabled</label><input id="enabled" type="checkbox" checked></div>
<div class="btns"><button onclick="createTarget()">新增</button><button class="secondary" onclick="loadTargets()">刷新列表</button></div>
<small id="tmsg"></small>
<div style="margin-top:10px;">
<table class="table" style="width:100%;border-collapse:collapse;font-size:13px;">
<thead><tr><th>Name</th><th>Host</th><th>Port</th><th>User</th><th>Enabled</th><th>操作</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
</div>
<script>
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]));}
async function load(){
try{
const d=await api('/api/v1/admin/cpa/settings');
document.getElementById('token').value = d.settings?.cpa_management_token || '';
document.getElementById('base').value = d.settings?.cpa_management_base || '';
document.getElementById('msg').textContent='已加载';
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
}
async function save(){
const token=document.getElementById('token').value.trim();
const base=document.getElementById('base').value.trim();
try{
await api('/api/v1/admin/cpa/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({management_token:token,management_base:base})});
document.getElementById('msg').textContent='已保存';
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
}
async function loadTargets(){
try{
const d=await api('/api/v1/admin/ops/targets');
const list=Array.isArray(d.targets)?d.targets:[];
const tbody=document.getElementById('tbody');
if(!list.length){tbody.innerHTML='<tr><td colspan="6"><small>暂无目标</small></td></tr>';return;}
tbody.innerHTML=list.map(t=>`<tr>
<td>${esc(t.name)}</td>
<td><input data-id="${t.id}" data-field="host" value="${esc(t.host)}"></td>
<td><input data-id="${t.id}" data-field="port" value="${esc(t.port)}"></td>
<td><input data-id="${t.id}" data-field="user" value="${esc(t.user)}"></td>
<td>${t.enabled?'<span class="badge on">ON</span>':'<span class="badge off">OFF</span>'}</td>
<td>
<button class="secondary" onclick="saveTarget(${t.id})">保存</button>
<button class="danger" onclick="toggleTarget(${t.id},${t.enabled?0:1})">${t.enabled?'禁用':'启用'}</button>
</td>
</tr>`).join('');
}catch(e){document.getElementById('tmsg').textContent='加载失败:'+esc(e.message||e);}
}
async function createTarget(){
const payload={
name:document.getElementById('name').value.trim(),
host:document.getElementById('host').value.trim(),
port:parseInt(document.getElementById('port').value||'22',10),
user:document.getElementById('user').value.trim(),
enabled:document.getElementById('enabled').checked
};
try{
await api('/api/v1/admin/ops/targets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
document.getElementById('tmsg').textContent='已新增';
await loadTargets();
}catch(e){document.getElementById('tmsg').textContent='失败:'+esc(e.message||e);}
}
async function saveTarget(id){
const inputs=[...document.querySelectorAll(`input[data-id="${id}"]`)];
const payload={enabled:true};
inputs.forEach(i=>{payload[i.dataset.field]=i.value.trim();});
payload.port=parseInt(payload.port||'22',10);
try{
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
await loadTargets();
}catch(e){alert('保存失败:'+(e.message||e));}
}
async function toggleTarget(id, enable){
try{
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!enable})});
await loadTargets();
}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(theme){
if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
else{document.documentElement.removeAttribute('data-theme');}
localStorage.setItem('theme',theme);
setThemeIcon(theme);
}
function toggleTheme(){
const cur=localStorage.getItem('theme')||'dark';
const next = cur==='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();
loadTargets();
})();
</script>
</body>
</html>

257
templates/index.html Normal file
View File

@@ -0,0 +1,257 @@
<!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;
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
--chip:#f3f4f6;
}
[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);
--chip:#111827;
}
*{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;align-items:center;justify-content:space-between;gap:12px}
.header h1{font-size:22px}
.header .subtitle{font-size:12px;opacity:.9}
.header .right{display:flex;align-items:center;gap:8px}
.header .right a,.header .right 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 .right button{width:28px;padding:0;transition:transform .2s ease}
.header .right button.spin{transform:rotate(180deg)}
.header .right button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
.wrap{max-width:980px;margin:0 auto;padding:14px}
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none}
.card h3{font-size:14px;margin-bottom:10px}
.stat{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;font-size:13px;color:var(--muted)}
.stat b{font-size:18px;color:var(--accent)}
.section{margin-top:12px}
.section-title{font-size:13px;color:var(--muted);margin:10px 2px}
.tags{display:flex;flex-wrap:wrap;gap:8px}
.tag{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;background:var(--card)}
.tag .dot{width:8px;height:8px;border-radius:50%}
.dot.ok{background:#22c55e}.dot.err{background:#ef4444}.dot.off{background:#9ca3af}
.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.actions a{display:inline-block;text-decoration:none;background:var(--accent);color:#fff;border-radius:6px;padding:8px 12px;font-size:13px;border:1px solid rgba(0,0,0,.06)}
.actions a.secondary{background:#6b7280}
.actions a.hidden{display:none}
.list{display:grid;gap:8px}
.job{border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;background:var(--card)}
.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}
.empty{padding:30px;text-align:center;color:var(--muted)}
@media(max-width:720px){
.grid{grid-template-columns:1fr}
.header{flex-direction:column;align-items:flex-start}
.header .right{align-self:flex-end}
}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>
<h1>🛠️ Ops-Assistant</h1>
<div class="subtitle">{{.version}}</div>
</div>
<div class="right">
<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="/logout">退出</a>
</div>
</div>
<div class="wrap">
<div class="grid">
<div class="card">
<h3>任务概览</h3>
<div class="stat"><span>Pending</span><b id="sPending">0</b></div>
<div class="stat"><span>Running</span><b id="sRunning">0</b></div>
<div class="stat"><span>Success</span><b id="sSuccess">0</b></div>
<div class="stat"><span>Failed</span><b id="sFailed">0</b></div>
<div class="stat"><span>Cancelled</span><b id="sCancelled">0</b></div>
<div class="actions">
<a id="btnOps" href="/ops" class="hidden">🛠️ 任务中心</a>
</div>
</div>
<div class="card">
<h3>模块状态</h3>
<div class="tags" id="modulesTag"></div>
<div class="actions">
<a id="btnCPA" href="/cpa" class="secondary hidden">🔧 CPA 配置</a>
<a id="btnCF" href="/cf" class="secondary hidden">☁️ CF 配置</a>
<a id="btnAI" href="/ai" class="secondary hidden" style="display:none">🤖 AI 配置</a>
<a id="btnModules" href="#" class="secondary hidden" onclick="toggleModulesPanel();return false;">⚙️ 模块开关</a>
</div>
</div>
<div class="card">
<h3>通道状态</h3>
<div class="tags" id="channelsTag"></div>
<div class="actions">
<a id="btnChannels" href="/channels" class="hidden">🔌 渠道配置</a>
<a id="btnAudit" href="/audit" class="secondary hidden">🧾 审计日志</a>
</div>
</div>
</div>
<div class="section">
<div class="section-title">最近任务</div>
<div id="recentJobs" class="list"></div>
</div>
</div>
<script>
let me=null;
const state={overview: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]);}
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 initUI(){
document.getElementById('btnOps').classList.toggle('hidden',!can('can_view_ops'));
document.getElementById('btnChannels').classList.toggle('hidden',!can('can_view_channels'));
document.getElementById('btnCPA').classList.toggle('hidden',!can('can_view_flags'));
document.getElementById('btnCF').classList.toggle('hidden',!can('can_view_flags'));
document.getElementById('btnAI').classList.toggle('hidden',!can('can_view_flags'));
document.getElementById('btnAudit').classList.toggle('hidden',!can('can_view_audit'));
document.getElementById('btnModules').classList.toggle('hidden',!can('can_view_flags'));
}
function renderOverview(){
const ov=state.overview||{};
const sc=ov.jobs?.status_count||{};
document.getElementById('sPending').textContent=sc.pending??0;
document.getElementById('sRunning').textContent=sc.running??0;
document.getElementById('sSuccess').textContent=sc.success??0;
document.getElementById('sFailed').textContent=sc.failed??0;
document.getElementById('sCancelled').textContent=sc.cancelled??0;
const mods=Array.isArray(ov.modules)?ov.modules:[];
document.getElementById('modulesTag').innerHTML=mods.length?mods.map(m=>{
const dot=m.enabled?'ok':'off';
return `<span class="tag"><span class="dot ${dot}"></span>${esc(m.module)}</span>`;
}).join(''):'<div class="empty">暂无模块</div>';
const chs=Array.isArray(ov.channels)?ov.channels:[];
document.getElementById('channelsTag').innerHTML=chs.length?chs.map(c=>{
const dot=c.status==='ok'?'ok':(c.enabled?'err':'off');
return `<span class="tag"><span class="dot ${dot}"></span>${esc(c.platform)}</span>`;
}).join(''):'<div class="empty">暂无通道</div>';
const jobs=Array.isArray(ov.jobs?.recent)?ov.jobs.recent:[];
const box=document.getElementById('recentJobs');
if(!jobs.length){ box.innerHTML='<div class="empty">暂无任务</div>'; return; }
box.innerHTML=jobs.map(j=>`<div class="job">
<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;">
<strong>#${j.id} ${esc(j.command||'')}</strong>
<span class="badge ${(j.status||'pending').toLowerCase()}">${esc(j.status||'pending')}</span>
</div>
<div style="color:#666;margin-top:4px;">runbook=${esc(j.runbook||'-')} · target=${esc(j.target||'-')}</div>
</div>`).join('');
}
async function loadOverview(){
state.overview=await api('/api/v1/dashboard/overview');
renderOverview();
}
function toggleModulesPanel(){
alert('模块开关请进入设置页(后续独立页面/弹窗)');
}
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(theme){
if(theme==='dark'){
document.documentElement.setAttribute('data-theme','dark');
}else{
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('theme',theme);
setThemeIcon(theme);
}
function toggleTheme(){
const cur=localStorage.getItem('theme')||'dark';
const next=cur==='light'?'dark':'light';
const btn=document.getElementById('themeToggle');
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
applyTheme(next);
}
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(); initUI(); await loadOverview(); setInterval(loadOverview,15000);}catch(e){
document.getElementById('recentJobs').innerHTML='<div class="empty">初始化失败:'+esc(e.message||e)+'</div>';
}
})();
</script>
</body>
</html>

142
templates/login.html Normal file
View File

@@ -0,0 +1,142 @@
<!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:#0f172a;
--card:#111827;
--text:#e5e7eb;
--muted:#9ca3af;
--border:#1f2937;
--accent:#f97316;
--accent2:#ea580c;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: radial-gradient(1200px 600px at 10% 10%, rgba(249,115,22,.18), transparent 60%),
radial-gradient(900px 500px at 90% 10%, rgba(234,88,12,.18), transparent 60%),
var(--bg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--text);
}
.login-card {
background: var(--card);
border-radius: 16px;
padding: 36px 30px;
width: 100%;
max-width: 380px;
border: 1px solid var(--border);
box-shadow: 0 20px 60px rgba(0,0,0,.35);
animation: slideUp .4s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.login-logo {
text-align: center;
margin-bottom: 24px;
}
.login-logo .icon { font-size: 48px; }
.login-logo h1 {
font-size: 22px;
color: var(--text);
margin-top: 8px;
}
.login-logo .subtitle {
font-size: 13px;
color: var(--muted);
margin-top: 4px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 15px;
transition: border-color .2s, box-shadow .2s;
outline: none;
background: #0b1220;
color: var(--text);
}
.form-group input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(249,115,22,.2);
}
.btn-login {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity .2s, transform .1s;
margin-top: 6px;
}
.btn-login:hover { opacity: .9; }
.btn-login:active { transform: scale(.98); }
.error-msg {
background: rgba(239,68,68,.12);
color: #fca5a5;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 14px;
text-align: center;
border: 1px solid rgba(239,68,68,.3);
}
</style>
</head>
<body>
<div class="login-card">
<div class="login-logo">
<div class="icon">🦞</div>
<h1>Ops-Assistant</h1>
<div class="subtitle">{{.version}}</div>
</div>
{{if .error}}
<div class="error-msg">{{.error}}</div>
{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" placeholder="请输入用户名" autocomplete="username" autofocus required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
</div>
<button class="btn-login" type="submit">登录</button>
</form>
</div>
</body>
</html>

229
templates/ops.html Normal file
View File

@@ -0,0 +1,229 @@
<!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>

145
templates/ops_targets.html Normal file
View File

@@ -0,0 +1,145 @@
<!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;--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: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}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}
.row label{width:120px;color:var(--muted);font-size:13px}
.row input{flex:1;min-width:160px;padding:8px;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}
.table{width:100%;border-collapse:collapse;font-size:13px}
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;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}
small{color:var(--muted)}
.theme-hidden{display:none !important;}
</style>
</head>
<body>
<div class="header">
<div>🎯 目标主机配置 · {{.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="card">
<h3>新增目标</h3>
<div class="row"><label>Name</label><input id="name" placeholder="如 hwsg"></div>
<div class="row"><label>Host</label><input id="host" placeholder="如 124.243.132.158"></div>
<div class="row"><label>Port</label><input id="port" placeholder="22"></div>
<div class="row"><label>User</label><input id="user" placeholder="root"></div>
<div class="row"><label>Enabled</label><input id="enabled" type="checkbox" checked></div>
<div class="row"><button onclick="create()">新增</button><small id="msg"></small></div>
</div>
<div class="card">
<h3>已有目标</h3>
<table class="table">
<thead><tr><th>Name</th><th>Host</th><th>Port</th><th>User</th><th>Enabled</th><th>操作</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<script>
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]));}
async function load(){
const d=await api('/api/v1/admin/ops/targets');
const list=Array.isArray(d.targets)?d.targets:[];
const tbody=document.getElementById('tbody');
if(!list.length){tbody.innerHTML='<tr><td colspan="6"><small>暂无目标</small></td></tr>';return;}
tbody.innerHTML=list.map(t=>`<tr>
<td>${esc(t.name)}</td>
<td><input data-id="${t.id}" data-field="host" value="${esc(t.host)}"></td>
<td><input data-id="${t.id}" data-field="port" value="${esc(t.port)}"></td>
<td><input data-id="${t.id}" data-field="user" value="${esc(t.user)}"></td>
<td>${t.enabled?'<span class="badge on">ON</span>':'<span class="badge off">OFF</span>'}</td>
<td>
<button class="secondary" onclick="save(${t.id})">保存</button>
<button class="danger" onclick="toggle(${t.id},${t.enabled?0:1})">${t.enabled?'禁用':'启用'}</button>
</td>
</tr>`).join('');
}
async function create(){
const payload={
name:document.getElementById('name').value.trim(),
host:document.getElementById('host').value.trim(),
port:parseInt(document.getElementById('port').value||'22',10),
user:document.getElementById('user').value.trim(),
enabled:document.getElementById('enabled').checked
};
try{
await api('/api/v1/admin/ops/targets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
document.getElementById('msg').textContent='已新增';
await load();
}catch(e){document.getElementById('msg').textContent='失败:'+esc(e.message||e);}
}
async function save(id){
const inputs=[...document.querySelectorAll(`input[data-id="${id}"]`)];
const payload={enabled:true};
inputs.forEach(i=>{payload[i.dataset.field]=i.value.trim();});
payload.port=parseInt(payload.port||'22',10);
try{
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
await load();
}catch(e){alert('保存失败:'+(e.message||e));}
}
async function toggle(id, enable){
try{
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!enable})});
await load();
}catch(e){alert('失败:'+(e.message||e));}
}
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(){
const saved=localStorage.getItem('theme')||'dark';
applyTheme(saved);
const btn=document.getElementById('themeToggle');
if(btn){btn.addEventListener('click',toggleTheme)}
load();
})();
</script>
</body>
</html>