Files
Nexus/web/app/settings.html
T
Your Name 9bba58a529 feat: Telegram test+ChatID, Agent IP allowlist/upgrade, alert history page
Telegram:
- POST /api/settings/telegram/test: send test message (checks token+chat_id)
- GET /api/settings/telegram/chats: call getUpdates, return up to 5 chats
- settings.html: test button + detect chat IDs with click-to-fill

Agent IP allowlist (web/agent/agent.py):
- allowed_ips config: list of trusted IPs/CIDRs from config.json
- _ip_allowed(): checks exact IP, CIDR (ipaddress module), hostname
- verify_api_key(): now checks IP allowlist before API key
- /health: also checks IP allowlist
- install.sh: auto-extracts central server IP from --url, adds to allowed_ips

Agent auto-upgrade (server/api/servers.py):
- POST /api/servers/{id}/upgrade-agent: SSH → curl new agent.py → systemctl restart
- servers.html: 升级 Agent button in Agent tab (only when online)

Alert history (new page):
- domain/models: AlertLog table (server_id, type, value, is_recovery, created_at)
- migrations.py: CREATE TABLE IF NOT EXISTS alert_logs
- websocket.py: _save_alert_log() called from broadcast_alert/recovery
- settings.py: GET /api/alert-history/ (paginated, filters), GET /stats
- main.py: register alert_history_router
- alerts.html: new page with stats cards, top-servers bar chart, alert list
- layout.js: 🔔 告警中心 added to sidebar nav

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 18:27:12 +08:00

858 lines
46 KiB
HTML
Raw 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>Nexus — 系统设置</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,totpStep:'idle',totpSecret:'',totpUri:''}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">系统设置</h1></div>
</header>
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full space-y-4">
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="text-slate-400 text-xs">以下为可修改的运行时配置。SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改。</div>
</div>
<!-- ── Account Security ── -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-white font-medium mb-4">账户安全</h2>
<!-- TOTP 2FA — 完整管理区块 -->
<div class="mb-5 pb-5 border-b border-slate-800 space-y-4">
<!-- 状态标题 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-white text-sm font-medium">Google Authenticator 双因素认证</span>
<span id="totpStatusBadge" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">检测中...</span>
</div>
</div>
<!-- 状态卡(已启用) -->
<div id="totpEnabledCard" class="hidden rounded-xl border border-green-500/30 bg-green-900/10 p-4">
<div class="flex items-start gap-3">
<span class="text-green-400 text-xl mt-0.5">🔐</span>
<div class="flex-1">
<p class="text-green-300 text-sm font-medium">双因素认证已启用</p>
<p class="text-green-400/70 text-xs mt-1">登录时需要输入 Google Authenticator(或兼容 App)生成的 6 位动态验证码。</p>
</div>
</div>
<div class="flex gap-2 mt-3">
<button onclick="startTotpSetup(true)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition">🔄 重新绑定(换设备/换 App</button>
<button onclick="disableTotp()" class="px-3 py-1.5 bg-red-900/40 hover:bg-red-900/70 text-red-400 text-xs rounded-lg transition">禁用 TOTP</button>
</div>
</div>
<!-- 状态卡(未启用) -->
<div id="totpDisabledCard" class="hidden rounded-xl border border-amber-500/30 bg-amber-900/10 p-4">
<div class="flex items-start gap-3">
<span class="text-amber-400 text-xl mt-0.5">⚠️</span>
<div class="flex-1">
<p class="text-amber-300 text-sm font-medium">尚未启用双因素认证</p>
<p class="text-amber-400/70 text-xs mt-1">启用后,登录时须额外输入 App 生成的验证码,有效防止密码泄露导致的账户被盗。</p>
</div>
</div>
<button onclick="startTotpSetup(false)" class="mt-3 px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">立即启用 TOTP →</button>
</div>
<!-- 绑定流程面板(启用和重新绑定共用) -->
<div id="totpBindPanel" class="hidden rounded-xl border border-brand/30 bg-brand/5 p-4 space-y-4">
<div class="flex items-center justify-between">
<span id="totpBindTitle" class="text-white text-sm font-semibold">绑定 Google Authenticator</span>
<button onclick="cancelTotpSetup()" class="text-slate-500 hover:text-white text-xs transition">✕ 取消</button>
</div>
<!-- 步骤说明 -->
<div class="text-slate-400 text-xs space-y-1">
<p>① 打开手机上的 <strong class="text-slate-300">Google Authenticator</strong>(或 Aegis、Microsoft Authenticator 等兼容 App</p>
<p>② 扫描下方二维码,或手动输入密钥</p>
<p>③ 输入 App 显示的 6 位数字确认绑定</p>
</div>
<!-- QR + 密钥 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
<div class="bg-slate-900 rounded-lg p-4 text-center border border-slate-700">
<p class="text-slate-500 text-xs mb-3">扫描二维码</p>
<canvas id="totpQrCanvas" class="mx-auto rounded" width="180" height="180"></canvas>
<p id="totpQrFallback" class="hidden text-slate-500 text-xs mt-2">二维码加载失败,请手动输入密钥</p>
</div>
<div class="space-y-3">
<div>
<label class="block text-slate-400 text-xs mb-1">手动输入密钥</label>
<div class="flex items-center gap-2">
<code id="totpSecretDisplay" class="flex-1 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all leading-relaxed"></code>
<button onclick="copyTotpSecret()" class="px-2 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition" title="复制密钥">📋</button>
</div>
<p class="text-slate-600 text-xs mt-1">密钥即使截图也无法替代 App 扫码,请妥善保管</p>
</div>
<div>
<label class="block text-slate-400 text-xs mb-1">输入 App 中的验证码</label>
<div class="flex items-center gap-2">
<input id="totpVerifyCode" maxlength="6" inputmode="numeric" pattern="\d{6}" placeholder="000000"
class="w-28 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-lg font-mono text-center tracking-[0.3em] focus:outline-none focus:ring-2 focus:ring-brand"
onkeydown="if(event.key==='Enter')verifyAndEnableTotp()">
<button onclick="verifyAndEnableTotp()" class="flex-1 px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition font-medium">确认绑定</button>
</div>
</div>
<button onclick="startTotpSetup(_isRebind)" class="text-xs text-slate-500 hover:text-slate-300 transition">🔄 重新生成二维码</button>
</div>
</div>
</div>
</div>
<!-- Change Password -->
<div>
<span class="text-white text-sm font-medium">修改密码</span>
<p class="text-slate-400 text-xs mb-3">修改当前账户的登录密码。</p>
<div class="space-y-2 max-w-sm">
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<button onclick="changePassword()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">修改密码</button>
</div>
</div>
</div>
<!-- Brand Settings -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-white font-medium mb-4">品牌</h2>
<div id="brandSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- API Key (read-only with copy) -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-white font-medium mb-4">API 密钥</h2>
<div id="apiKeySection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Alert Thresholds -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-white font-medium mb-4">告警阈值 (%)</h2>
<div id="alertSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Infrastructure -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-white font-medium mb-4">基础设施</h2>
<div id="infraSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Telegram -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<h2 class="text-white font-medium">Telegram 通知</h2>
<div id="telegramSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
<!-- Test + Chat ID detection -->
<div class="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-800">
<button onclick="telegramTest()" id="telegramTestBtn"
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
📨 发送测试消息
</button>
<button onclick="telegramGetChats()" id="telegramChatBtn"
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
🔍 检测 Chat ID
</button>
<span class="text-slate-600 text-xs">检测前请先向 Bot 发一条消息</span>
</div>
<!-- Chat list -->
<div id="telegramChatList" class="hidden space-y-2"></div>
</div>
<!-- Login IP Allowlist -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-white font-medium">登录 IP 白名单</h2>
<p class="text-slate-500 text-xs mt-0.5">开启后仅白名单 IP 可登录后台;关闭则不限制来源 IP</p>
</div>
<!-- Master toggle -->
<div class="flex items-center gap-3">
<span id="ipAllowlistStatus" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">加载中</span>
<button id="allowlistToggleBtn" onclick="toggleAllowlist()"
class="w-12 h-6 rounded-full transition-colors bg-slate-700 relative"
data-enabled="false" title="点击启用/关闭白名单">
<span id="allowlistToggleDot" class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"></span>
</button>
</div>
</div>
<!-- Disabled notice -->
<div id="allowlistDisabledNotice" class="rounded-lg border border-slate-700 bg-slate-800/40 px-4 py-3 text-slate-400 text-xs">
⚪ 白名单未启用 — 任何 IP 均可登录。开启后将仅允许下方白名单 IP。
</div>
<div id="allowlistEnabledNotice" class="hidden rounded-lg border border-green-500/30 bg-green-900/10 px-4 py-3 text-green-300 text-xs">
🟢 白名单已启用 — 仅白名单内的 IP 可登录。请确保当前 IP 已在列表中,否则自己也会被锁定!
</div>
<!-- 订阅 URL(持久存储,每 2 小时自动刷新) -->
<div class="rounded-xl border border-slate-700 bg-slate-800/30 p-4 space-y-3">
<div class="flex items-center justify-between">
<label class="text-slate-300 text-sm font-medium">代理订阅地址(自动刷新)</label>
<span id="lastRefreshTime" class="text-slate-600 text-xs"></span>
</div>
<div class="flex gap-2">
<input id="subUrl" type="url" placeholder="https://your-subscription-url..."
class="flex-1 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-brand">
<button onclick="saveSubscriptionUrl()" id="saveSubBtn"
class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition shrink-0">
保存
</button>
<button onclick="forceRefresh()" id="refreshSubBtn"
class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition shrink-0" title="立即刷新">
🔄
</button>
</div>
<p class="text-slate-600 text-xs">支持 SS / VMess / VLESS / Trojan / Hysteria2 订阅 · 系统启动后 + 每 2 小时自动拉取更新节点 IP</p>
</div>
<!-- 解析结果预览 -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<button onclick="previewSubscription()" id="parseSubBtn"
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
🔍 预览当前订阅节点 IP
</button>
<span class="text-slate-600 text-xs">(不更改白名单,仅查看)</span>
</div>
</div>
<!-- 解析结果 (待确认) -->
<div id="parsedIpsSection" class="hidden space-y-2">
<div class="flex items-center justify-between">
<label class="text-slate-400 text-xs">解析到的节点 IP(勾选要加入白名单的)</label>
<div class="flex gap-2">
<button onclick="selectAllParsed(true)" class="text-brand-light text-xs hover:underline">全选</button>
<button onclick="selectAllParsed(false)" class="text-slate-500 text-xs hover:underline">全不选</button>
</div>
</div>
<div id="parsedIpsList" class="max-h-40 overflow-y-auto rounded-lg border border-slate-700 bg-slate-950 p-2 space-y-1"></div>
<button onclick="addSelectedToWhitelist()"
class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">
➕ 添加到白名单
</button>
</div>
<!-- 手动输入 -->
<div class="space-y-2">
<label class="block text-slate-400 text-xs">手动添加 IP / CIDR / 域名(每行一条)</label>
<textarea id="manualIps" rows="3" placeholder="1.2.3.4&#10;10.0.0.0/8&#10;proxy.example.com"
class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono resize-y placeholder-slate-700"></textarea>
<button onclick="addManualIps()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">
添加
</button>
</div>
<!-- 订阅节点 IP(只读,自动刷新) -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-slate-400 text-xs">订阅节点 IP <span class="text-slate-600">(只读,每 2 小时自动替换)</span></label>
<span id="subIpCount" class="text-slate-600 text-xs"></span>
</div>
<div id="subscriptionIpsList" class="min-h-8 text-slate-500 text-xs">加载中...</div>
</div>
<!-- 手动 IP -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-slate-400 text-xs">手动添加的 IP <span class="text-slate-600">(永久保留)</span></label>
<button onclick="clearManualIps()" class="text-red-400 text-xs hover:underline">清空手动 IP</button>
</div>
<div id="manualIpsList" class="min-h-8 text-slate-500 text-xs">加载中...</div>
</div>
</div>
<!-- Notification toggles -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="flex items-center justify-between mb-1">
<h2 class="text-white font-medium">告警通知项目</h2>
<span class="text-slate-500 text-xs">通过 Telegram 推送,可逐项关闭</span>
</div>
<p class="text-slate-500 text-xs mb-4">关闭后该类告警仍记录日志,但不发送 Telegram 消息。Telegram Bot Token 未配置时全部静默。</p>
<div class="space-y-1" id="notifyToggles">
<div class="text-slate-500 text-center py-4 text-sm">加载中...</div>
</div>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script src="/app/vendor/qrious.min.js"></script>
<script>
initLayout('settings');
const SECTIONS={
brand:{keys:['system_name','system_title'],labels:{system_name:'系统名称',system_title:'页面标题'}},
alert:{keys:['cpu_alert_threshold','mem_alert_threshold','disk_alert_threshold'],labels:{cpu_alert_threshold:'CPU 告警阈值',mem_alert_threshold:'内存告警阈值',disk_alert_threshold:'磁盘告警阈值'}},
infra:{keys:['db_pool_size','db_max_overflow','heartbeat_timeout','redis_url'],labels:{db_pool_size:'连接池大小',db_max_overflow:'最大溢出',heartbeat_timeout:'心跳超时(秒)',redis_url:'Redis URL'}},
telegram:{keys:['telegram_bot_token','telegram_chat_id'],labels:{telegram_bot_token:'Bot Token',telegram_chat_id:'Chat ID'}},
};
let _allSettings={};
let _currentAdminId=null;
let _totpSecret='';
let _totpUri='';
// ── Load settings + admin info ──
async function loadSettings(){
try{
const r=await apiFetch(API+'/settings/');if(!r)return;
const settings=await r.json();
_allSettings={};settings.forEach(s=>_allSettings[s.key]=s.value);
for(const[section,cfg]of Object.entries(SECTIONS)){
const el=document.getElementById(section+'Section');
el.innerHTML=cfg.keys.map(key=>{
const val=_allSettings[key]||'';
const isMasked=val.includes('***')||val.includes('...');
if(isMasked){
return `<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<span class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm select-none">${esc(val)}</span>
<span class="text-slate-600 text-xs">🔒 安全锁定</span>
</div>`;
}
return `<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<input id="set_${key}" value="${esc(val)}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<button type="button" data-setting-key="${escAttr(key)}" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
</div>`;
}).join('');
}
// API Key section (read-only + copy)
renderApiKeySection();
// Notification toggles
renderNotifyToggles(_allSettings);
}catch(e){}
}
function renderApiKeySection(){
const val=_allSettings['api_key']||'';
const isMasked=val.includes('***')||val.includes('...');
const el=document.getElementById('apiKeySection');
if(isMasked){
el.innerHTML=`
<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
<span id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none">${esc(val)}</span>
<button onclick="toggleApiKeyVisibility()" id="apiKeyToggleBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
<span class="text-slate-600 text-xs">🔒 不可修改</span>
</div>
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
}else{
el.innerHTML=`
<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
<code id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all">${esc(val)}</code>
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
</div>
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
}
}
let _apiKeyRaw='';
async function toggleApiKeyVisibility(){
const btn=document.getElementById('apiKeyToggleBtn');
const display=document.getElementById('apiKeyDisplay');
if(btn.textContent.trim()==='显示'){
const pwd=prompt('请输入当前管理员密码以查看 API Key:');
if(!pwd)return;
try{
const r=await apiFetch(API+'/settings/api-key/reveal',{
method:'POST',
headers:apiHeadersJSON(),
body:JSON.stringify({current_password:pwd}),
});
if(!r)return;
const data=await r.json();
_apiKeyRaw=data.value||'';
display.textContent=_apiKeyRaw||'(空)';
display.className='flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all';
btn.textContent='隐藏';
}catch(e){}
}else{
display.textContent=_allSettings['api_key']||'';
display.className='flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none';
btn.textContent='显示';
_apiKeyRaw='';
}
}
function copyApiKey(){
if(_apiKeyRaw){copyText(_apiKeyRaw);return}
// Read from display element (works for both masked and non-masked states)
const display=document.getElementById('apiKeyDisplay');
const text=display.textContent||'';
if(text&&text!=='(空)'&&!text.includes('***')&&!text.includes('...')){
copyText(text);
}else{
// Need to reveal first
toast('info','请先点击"显示"按钮查看完整密钥');
}
}
function copyTotpSecret(){
const el=document.getElementById('totpSecretDisplay');
copyText(el.textContent);
}
function copyText(text){
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(()=>toast('success','已复制到剪贴板')).catch(()=>fallbackCopy(text));
}else{
fallbackCopy(text);
}
}
function fallbackCopy(text){
const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';
document.body.appendChild(ta);ta.select();
try{document.execCommand('copy');toast('success','已复制到剪贴板')}catch(e){toast('error','复制失败')}
document.body.removeChild(ta);
}
async function saveSetting(key){
const v=document.getElementById('set_'+key).value;
const r=await apiFetch(API+'/settings/'+key,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:v})});
if(r&&r.ok)toast('success','设置已保存');else toast('error','保存失败');
}
// ── TOTP Management ──
let _isRebind = false; // true = 重新绑定(已启用时),false = 首次启用
async function loadTotpStatus(){
try{
const r=await apiFetch(API+'/auth/me');if(!r)return;
const admin=await r.json();
_currentAdminId=admin.id;
updateTotpUI(admin.totp_enabled);
}catch(e){}
}
function updateTotpUI(enabled){
const badge=document.getElementById('totpStatusBadge');
const enabledCard=document.getElementById('totpEnabledCard');
const disabledCard=document.getElementById('totpDisabledCard');
const bindPanel=document.getElementById('totpBindPanel');
bindPanel.classList.add('hidden');
document.getElementById('totpVerifyCode').value='';
if(enabled){
badge.textContent='✓ 已启用';
badge.className='text-xs px-2 py-0.5 rounded-full bg-green-900/50 text-green-400';
enabledCard.classList.remove('hidden');
disabledCard.classList.add('hidden');
}else{
badge.textContent='未启用';
badge.className='text-xs px-2 py-0.5 rounded-full bg-amber-900/40 text-amber-400';
enabledCard.classList.add('hidden');
disabledCard.classList.remove('hidden');
}
}
async function startTotpSetup(isRebind=false){
if(!_currentAdminId){toast('error','未获取用户信息');return}
_isRebind = isRebind;
const bindPanel=document.getElementById('totpBindPanel');
const title=document.getElementById('totpBindTitle');
const qrCanvas=document.getElementById('totpQrCanvas');
const qrFallback=document.getElementById('totpQrFallback');
title.textContent = isRebind ? '重新绑定 Google Authenticator' : '绑定 Google Authenticator';
bindPanel.classList.remove('hidden');
document.getElementById('totpSecretDisplay').textContent='生成中...';
document.getElementById('totpVerifyCode').value='';
qrFallback.classList.add('hidden');
// Scroll to panel
setTimeout(()=>bindPanel.scrollIntoView({behavior:'smooth',block:'nearest'}),100);
try{
const r=await apiFetch(API+'/auth/totp/setup',{
method:'POST',headers:apiHeadersJSON(),
body:JSON.stringify({admin_id:_currentAdminId})
});
if(!r)return;
const data=await r.json();
if(!data.success){toast('error',data.reason||'设置失败');return}
_totpSecret=data.secret;
_totpUri=data.uri;
document.getElementById('totpSecretDisplay').textContent=data.secret;
// Generate QR code
try{
new QRious({
element:qrCanvas,
value:data.uri,
size:180,
backgroundAlpha:0,
foreground:'#e2e8f0',
background:'transparent',
level:'M'
});
qrCanvas.style.display='block';
}catch(e){
qrCanvas.style.display='none';
qrFallback.classList.remove('hidden');
}
// Focus verify input
setTimeout(()=>document.getElementById('totpVerifyCode').focus(),300);
}catch(e){toast('error','TOTP生成请求失败')}
}
async function verifyAndEnableTotp(){
const code=document.getElementById('totpVerifyCode').value.trim();
if(!/^\d{6}$/.test(code)){toast('warning','请输入 6 位纯数字验证码');return}
try{
const r=await apiFetch(API+'/auth/totp/enable',{
method:'POST',headers:apiHeadersJSON(),
body:JSON.stringify({admin_id:_currentAdminId,totp_code:code})
});
if(!r)return;
const data=await r.json();
if(data.success){
toast('success', _isRebind ? '重新绑定成功!新设备已生效' : 'TOTP 已启用!下次登录需要验证码');
updateTotpUI(true);
}else{
toast('error',data.message||data.reason||'验证码错误,请检查手机时间并重试');
}
}catch(e){toast('error','启用失败')}
}
async function disableTotp(){
if(!confirm('确定要禁用双因素认证?\n禁用后登录将不再需要验证码,账户安全性降低。'))return;
const pwd=prompt('请输入当前登录密码:');
if(!pwd)return;
const code=prompt('请输入当前 TOTP 验证码(来自 App):');
if(!code)return;
try{
const r=await apiFetch(API+'/auth/totp/disable',{
method:'POST',headers:apiHeadersJSON(),
body:JSON.stringify({admin_id:_currentAdminId,current_password:pwd,totp_code:code.trim()})
});
if(!r)return;
const data=await r.json();
if(data.success){
toast('success','TOTP 已禁用');
updateTotpUI(false);
}else{
toast('error',data.message||'禁用失败,密码或验证码错误');
}
}catch(e){toast('error','禁用请求失败')}
}
function cancelTotpSetup(){
document.getElementById('totpBindPanel').classList.add('hidden');
document.getElementById('totpVerifyCode').value='';
}
// ── Change Password ──
async function changePassword(){
const current=document.getElementById('currentPassword').value;
const newPw=document.getElementById('newPassword').value;
const confirm=document.getElementById('confirmPassword').value;
if(!current||!newPw||!confirm){toast('warning','请填写所有密码字段');return}
if(newPw.length<6){toast('warning','新密码至少6位');return}
if(newPw!==confirm){toast('warning','两次输入的新密码不一致');return}
try{
const r=await apiFetch(API+'/auth/password',{
method:'PUT',headers:apiHeadersJSON(),
body:JSON.stringify({current_password:current,new_password:newPw})
});
if(!r){toast('error','请求失败');return}
if(r.ok){
const data=await r.json();
toast('success','密码已修改');
document.getElementById('currentPassword').value='';
document.getElementById('newPassword').value='';
document.getElementById('confirmPassword').value='';
}else{
const err=await r.json().catch(()=>({}));
toast('error',err.detail||'修改失败');
}
}catch(e){toast('error','修改请求失败')}
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function escAttr(s){if(!s)return'';return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
document.addEventListener('click',e=>{
const btn=e.target.closest('.save-setting-btn');
if(!btn)return;
const key=btn.getAttribute('data-setting-key');
if(key)saveSetting(key);
});
// ── Telegram Test + Chat ID Detection ──
async function telegramTest(){
const btn=document.getElementById('telegramTestBtn');
btn.disabled=true;btn.textContent='发送中...';
try{
const r=await apiFetch(API+'/settings/telegram/test',{method:'POST',headers:apiHeadersJSON()});
if(!r)return;
const d=await r.json();
if(d.success)toast('success','Telegram 测试消息发送成功!');
else toast('error','发送失败: '+(d.detail||d.error||'未知错误'));
}catch(e){toast('error','请求失败: '+e.message)}
finally{btn.disabled=false;btn.textContent='📨 发送测试消息'}
}
async function telegramGetChats(){
const btn=document.getElementById('telegramChatBtn');
btn.disabled=true;btn.textContent='检测中...';
try{
const r=await apiFetch(API+'/settings/telegram/chats');
if(!r)return;
const d=await r.json();
const listEl=document.getElementById('telegramChatList');
if(!d.chats||d.chats.length===0){
toast('warning','未检测到消息,请先向 Bot 发一条消息后再试');
listEl.classList.add('hidden');return;
}
listEl.classList.remove('hidden');
listEl.innerHTML='<p class="text-slate-400 text-xs">检测到以下对话,点击填入 Chat ID</p>'+
d.chats.map(c=>`<div class="flex items-center gap-3 px-3 py-2 bg-slate-800 rounded-lg">
<div class="flex-1">
<span class="text-white text-sm font-medium">${esc(c.title||c.username||'私聊')}</span>
<span class="ml-2 text-slate-500 text-xs">${esc(c.type)}</span>
<code class="ml-2 text-brand-light text-xs font-mono">${esc(String(c.id))}</code>
</div>
<button onclick="fillChatId('${escAttr(String(c.id))}')"
class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg transition">
使用此 ID
</button>
</div>`).join('');
toast('success',`检测到 ${d.chats.length} 个对话`);
}catch(e){toast('error','检测失败: '+e.message)}
finally{btn.disabled=false;btn.textContent='🔍 检测 Chat ID'}
}
async function fillChatId(chatId){
// Save chat_id to settings
const r=await apiFetch(API+'/settings/telegram_chat_id',{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:chatId})});
if(r&&r.ok){
toast('success','Chat ID 已保存: '+chatId);
// Update the input field in telegramSection
const input=document.getElementById('set_telegram_chat_id');
if(input)input.value=chatId;
loadSettings();
}else toast('error','保存失败');
}
// ── Notification Toggles ──
const NOTIFY_ITEMS = [
{ key:'notify_alert_cpu', label:'CPU 超阈值告警', desc:'CPU 使用率超过设定阈值时推送', group:'服务器资源' },
{ key:'notify_alert_mem', label:'内存超阈值告警', desc:'内存使用率超过设定阈值时推送', group:'服务器资源' },
{ key:'notify_alert_disk', label:'磁盘超阈值告警', desc:'磁盘使用率超过设定阈值时推送', group:'服务器资源' },
{ key:'notify_recovery', label:'资源恢复正常', desc:'CPU/内存/磁盘告警解除时推送', group:'服务器资源' },
{ key:'notify_time_drift', label:'时钟偏差严重 (≥60s)', desc:'子机与主机时间偏差过大影响 TOTP/cron',group:'服务器' },
{ key:'notify_system_redis', label:'Redis 连接异常 / 恢复', desc:'Redis 不可达或恢复时推送', group:'系统' },
{ key:'notify_system_mysql', label:'MySQL 连接异常 / 恢复', desc:'MySQL 不可达或恢复时推送', group:'系统' },
{ key:'notify_restart', label:'Nexus 后端重启', desc:'Python 服务崩溃重启成功/失败时推送', group:'系统' },
];
function renderNotifyToggles(settingsMap){
const container=document.getElementById('notifyToggles');
let lastGroup='';
container.innerHTML=NOTIFY_ITEMS.map(item=>{
const val=(settingsMap[item.key]||'true').trim().toLowerCase();
const enabled=!['false','0','no','off'].includes(val);
let groupHeader='';
if(item.group!==lastGroup){
lastGroup=item.group;
groupHeader=`<div class="text-slate-500 text-xs font-semibold uppercase tracking-wide pt-3 pb-1 border-t border-slate-800 first:border-0">${esc(item.group)}</div>`;
}
return groupHeader+`
<div class="flex items-center justify-between py-2.5 px-3 rounded-lg hover:bg-slate-800/50 transition group">
<div class="flex-1 min-w-0">
<span class="text-sm text-slate-200">${esc(item.label)}</span>
<span class="block text-xs text-slate-500 mt-0.5">${esc(item.desc)}</span>
</div>
<button onclick="toggleNotify('${escAttr(item.key)}',this)"
class="ml-4 shrink-0 w-11 h-6 rounded-full transition-colors ${enabled?'bg-brand':'bg-slate-700'} relative"
data-enabled="${enabled}" title="${enabled?'点击关闭':'点击启用'}">
<span class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${enabled?'translate-x-5':'translate-x-0'}"></span>
</button>
</div>`;
}).join('');
}
async function toggleNotify(key, btn){
const nowEnabled=btn.getAttribute('data-enabled')==='true';
const newEnabled=!nowEnabled;
const newVal=newEnabled?'true':'false';
// Optimistic UI update
btn.setAttribute('data-enabled',String(newEnabled));
btn.className=btn.className.replace(newEnabled?'bg-slate-700':'bg-brand', newEnabled?'bg-brand':'bg-slate-700');
const dot=btn.querySelector('span');
dot.className=dot.className.replace(newEnabled?'translate-x-0':'translate-x-5', newEnabled?'translate-x-5':'translate-x-0');
btn.title=newEnabled?'点击关闭':'点击启用';
const r=await apiFetch(API+'/settings/'+key,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:newVal})});
if(!r||!r.ok){
// Revert
btn.setAttribute('data-enabled',String(nowEnabled));
toast('error','保存失败,已撤销');
renderNotifyToggles(_allSettings);
}else{
_allSettings[key]=newVal;
toast('success',newEnabled?'已启用通知':'已关闭通知');
}
}
// ── IP Allowlist ──
let _subIps = [];
let _manualIps = [];
let _parsedIps = [];
let _allowlistEnabled = false;
async function loadAllowlist(){
try{
const r=await apiFetch(API+'/settings/ip-allowlist');if(!r)return;
const d=await r.json();
_subIps=d.subscription_ips||[];
_manualIps=d.manual_ips||[];
_allowlistEnabled=d.enabled||false;
// Fill subscription URL
if(d.subscription_url)document.getElementById('subUrl').value=d.subscription_url;
// Last refresh
const lrt=document.getElementById('lastRefreshTime');
if(d.last_refresh)lrt.textContent='上次刷新: '+d.last_refresh;
else if(d.subscription_url)lrt.textContent='等待首次刷新...';
// Toggle button
const btn=document.getElementById('allowlistToggleBtn');
const dot=document.getElementById('allowlistToggleDot');
btn.setAttribute('data-enabled',String(_allowlistEnabled));
btn.className=`w-12 h-6 rounded-full transition-colors ${_allowlistEnabled?'bg-brand':'bg-slate-700'} relative`;
dot.className=`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${_allowlistEnabled?'translate-x-6':'translate-x-0.5'}`;
// Notices
document.getElementById('allowlistDisabledNotice').classList.toggle('hidden',_allowlistEnabled);
document.getElementById('allowlistEnabledNotice').classList.toggle('hidden',!_allowlistEnabled);
// Status badge
const status=document.getElementById('ipAllowlistStatus');
if(_allowlistEnabled){
status.textContent=`已启用 · ${d.total_count} 条`;
status.className='text-xs px-2 py-0.5 rounded-full bg-green-900/50 text-green-400 border border-green-500/30';
}else{
status.textContent='已关闭';
status.className='text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500';
}
// Subscription IPs (read-only)
document.getElementById('subIpCount').textContent=`${_subIps.length} 个`;
document.getElementById('subscriptionIpsList').innerHTML=_subIps.length
?'<div class="flex flex-wrap gap-1.5">'+_subIps.map(ip=>`<span class="px-2 py-0.5 rounded border border-slate-700 bg-slate-800 text-xs font-mono text-slate-400">${esc(ip)}</span>`).join('')+'</div>'
:'<span class="text-slate-600">(空 — 尚未刷新或未配置订阅)</span>';
// Manual IPs (deletable)
document.getElementById('manualIpsList').innerHTML=_manualIps.length
?'<div class="flex flex-wrap gap-1.5">'+_manualIps.map(ip=>`<span class="inline-flex items-center px-2 py-0.5 rounded border border-brand/40 bg-brand/10 text-brand-light text-xs font-mono">${esc(ip)}<button onclick="removeManualIp('${escAttr(ip)}')" class="ml-1 text-slate-500 hover:text-red-400">×</button></span>`).join('')+'</div>'
:'<span class="text-slate-600">(空)</span>';
}catch(e){}
}
async function toggleAllowlist(){
const btn=document.getElementById('allowlistToggleBtn');
const nowEnabled=btn.getAttribute('data-enabled')==='true';
const newEnabled=!nowEnabled;
if(newEnabled&&_subIps.length===0&&_manualIps.length===0){
toast('warning','白名单为空,启用后无人可以登录!请先添加 IP 再启用。');return;
}
if(newEnabled&&!confirm('确认启用 IP 白名单?\n启用后非白名单 IP 将无法登录,请确认当前 IP 已在列表中。'))return;
const r=await apiFetch(API+'/settings/ip-allowlist/toggle?enabled='+newEnabled,{method:'POST',headers:apiHeadersJSON()});
if(r&&r.ok){toast('success',newEnabled?'白名单已启用':'白名单已关闭');loadAllowlist();}
else toast('error','操作失败');
}
async function saveSubscriptionUrl(){
const url=document.getElementById('subUrl').value.trim();
const btn=document.getElementById('saveSubBtn');
btn.disabled=true;btn.textContent='保存中...';
try{
const r=await apiFetch(API+'/settings/ip-allowlist',{method:'POST',headers:apiHeadersJSON(),
body:JSON.stringify({manual_ips:_manualIps,subscription_url:url})});
if(r&&r.ok){toast('success',url?'订阅地址已保存,正在刷新节点 IP...':'订阅地址已清空');setTimeout(loadAllowlist,3500)}
else{const e=await r?.json().catch(()=>({detail:'保存失败'}));toast('error',e.detail||'保存失败')}
}finally{btn.disabled=false;btn.textContent='保存'}
}
async function forceRefresh(){
const btn=document.getElementById('refreshSubBtn');
btn.textContent='⏳';btn.disabled=true;
const url=(document.getElementById('subUrl').value||'').trim();
if(!url){toast('warning','请先填写订阅地址');btn.textContent='🔄';btn.disabled=false;return}
await saveSubscriptionUrl();
setTimeout(()=>{btn.textContent='🔄';btn.disabled=false;loadAllowlist()},4000);
}
async function previewSubscription(){
const url=(document.getElementById('subUrl').value||'').trim();
if(!url){toast('warning','请先填写订阅地址');return}
const btn=document.getElementById('parseSubBtn');
btn.disabled=true;btn.textContent='预览中...';
try{
const r=await apiFetch(API+'/settings/ip-allowlist/parse-subscription',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({url})});
if(!r||!r.ok){const e=await r?.json().catch(()=>({detail:'解析失败'}));toast('error',e.detail||'解析失败');return}
const d=await r.json();
_parsedIps=d.hosts||[];
const section=document.getElementById('parsedIpsSection');
const list=document.getElementById('parsedIpsList');
section.classList.remove('hidden');
list.innerHTML=_parsedIps.map(ip=>`
<label class="flex items-center gap-2 cursor-pointer hover:bg-slate-800 px-1 rounded py-0.5">
<input type="checkbox" checked class="parsed-ip-cb rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand" data-ip="${escAttr(ip)}">
<span class="font-mono text-xs text-slate-300">${esc(ip)}</span>
</label>`).join('');
toast('success',`预览到 ${d.count} 个节点 IP(保存订阅后自动刷新)`);
}catch(e){toast('error','请求失败: '+e.message)}
finally{btn.disabled=false;btn.textContent='🔍 预览当前订阅节点 IP'}
}
function selectAllParsed(checked){
document.querySelectorAll('.parsed-ip-cb').forEach(cb=>cb.checked=checked);
}
function addSelectedToWhitelist(){
const selected=Array.from(document.querySelectorAll('.parsed-ip-cb:checked')).map(cb=>cb.dataset.ip);
if(!selected.length){toast('warning','请勾选至少一个 IP');return}
addManualIpsToList(selected);
document.getElementById('parsedIpsSection').classList.add('hidden');
}
async function addManualIps(){
const raw=document.getElementById('manualIps').value;
const newIps=raw.split(/[\n,]/).map(s=>s.trim()).filter(Boolean);
if(!newIps.length){toast('warning','请输入 IP');return}
await addManualIpsToList(newIps);
document.getElementById('manualIps').value='';
}
async function addManualIpsToList(newIps){
const r=await apiFetch(API+'/settings/ip-allowlist/manual',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({ips:newIps})});
if(r&&r.ok){toast('success',`已添加 ${newIps.length} 条手动 IP`);setTimeout(loadAllowlist,1500)}
else toast('error','添加失败');
}
async function removeManualIp(ip){
const r=await apiFetch(API+'/settings/ip-allowlist/ip?ip='+encodeURIComponent(ip),{method:'DELETE',headers:apiHeadersJSON()});
if(r&&r.ok){toast('success','已移除: '+ip);setTimeout(loadAllowlist,1000)}
else toast('error','移除失败');
}
async function clearManualIps(){
if(!confirm('确定清空所有手动添加的 IP?订阅节点不受影响。'))return;
const r=await apiFetch(API+'/settings/ip-allowlist',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({manual_ips:[]})});
if(r&&r.ok){toast('success','手动 IP 已清空');setTimeout(loadAllowlist,1000)}
}
// ── Init ──
loadSettings();
loadTotpStatus();
loadAllowlist();
</script>
</body></html>