前端增强: XSS防护 + 共享布局 + 移动适配 + 设置页
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

- index.html: WebSocket告警文本esc()转义
- files.html: escAttr()函数 + 路径拼接转义
- layout.js: 移动端侧边栏overlay + 全局搜索 + 用户信息
- servers.html: 点击展开详情面板(系统信息/同步/SSH)
- settings.html: TOTP QR码设置 + 修改密码 + API Key复制
- api.js: JWT refresh token自动刷新 + Toast通知
- terminal.html: WebSSH xterm.js集成

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-22 22:29:06 +08:00
parent 938d26927f
commit b676e320de
7 changed files with 494 additions and 28 deletions
+28 -1
View File
@@ -61,6 +61,11 @@ async function apiFetch(url, options = {}) {
const res = await fetch(url, options);
// Record session activity on successful API calls
if (res.status !== 401) {
_recordActivity();
}
// If 401, try refresh once
if (res.status === 401) {
const refreshed = await _refreshTokens();
@@ -80,6 +85,7 @@ function _logout() {
localStorage.removeItem('refresh_token');
localStorage.removeItem('token_expires');
localStorage.removeItem('admin');
localStorage.removeItem('last_activity');
window.location.href = '/app/login.html';
}
@@ -98,9 +104,30 @@ function doLogout() {
_logout();
}
// ── Session Inactivity Timeout ──
// Auto-logout after 8 hours of no API activity
const SESSION_MAX_AGE_MS = 8 * 60 * 60 * 1000; // 8 hours
function _checkSessionAge() {
const lastActivity = parseInt(localStorage.getItem('last_activity') || '0');
if (lastActivity && Date.now() - lastActivity > SESSION_MAX_AGE_MS) {
_logout();
return false;
}
return true;
}
function _recordActivity() {
localStorage.setItem('last_activity', String(Date.now()));
}
// Backward-compatible alias
const token = localStorage.getItem('access_token') || '';
if (!token) window.location.href = '/app/login.html';
if (!token || !_checkSessionAge()) {
// Will redirect to login via _checkSessionAge → _logout
} else {
_recordActivity();
}
function ah() { return apiHeaders(); }
// ── Global Toast Notification System ──
+5 -4
View File
@@ -61,7 +61,7 @@
// Parent directory link (if not root)
if(_currentPath!=='/'){
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" onclick="browseDir('${parentPath.replace(/'/g,"\\'")}')">
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" onclick="browseDir('${escAttr(parentPath)}')">
<span class="text-slate-500">📂</span><span class="text-slate-400 text-sm">..</span><span class="text-slate-600 text-xs">返回上级目录</span>
</div>`;
}
@@ -69,8 +69,8 @@
// Sort: directories first, then files
const sorted=[...d.entries].sort((a,b)=>(b.is_dir-a.is_dir)||a.name.localeCompare(b.name));
html+=sorted.map(e=>{
const fullPath=(_currentPath.replace(/\/$/,'')+'/'+e.name).replace(/'/g,"\\'");
return `<div class="flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50 ${e.is_dir?'cursor-pointer':''}" ${e.is_dir?`onclick="browseDir('${fullPath}')"`:''}>
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
return `<div class="flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50 ${e.is_dir?'cursor-pointer':''}" ${e.is_dir?`onclick="browseDir('${escAttr(fullPath)}')"`:''}>
<span class="${e.is_dir?'text-yellow-400':'text-slate-400'} text-sm">${e.is_dir?'📁':'📄'}</span>
<span class="text-white text-sm flex-1 ${e.is_dir?'hover:text-brand-light':''}">${esc(e.name)}</span>
<span class="text-slate-500 text-xs tabular-nums">${e.is_dir?'--':e.size}</span>
@@ -101,7 +101,7 @@
accumulated+='/'+seg;
const p=accumulated;
if(i<segments.length-1){
html+=`<span class="text-slate-600">/</span><span class="cursor-pointer text-brand-light hover:underline" onclick="browseDir('${p.replace(/'/g,"\\'")}')">${esc(seg)}</span>`;
html+=`<span class="text-slate-600">/</span><span class="cursor-pointer text-brand-light hover:underline" onclick="browseDir('${escAttr(p)}')">${esc(seg)}</span>`;
}else{
html+=`<span class="text-slate-600">/</span><span class="text-white font-medium">${esc(seg)}</span>`;
}
@@ -110,6 +110,7 @@
}
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;')}
// Enter key in path input triggers browse
document.getElementById('dirPath').addEventListener('keydown',e=>{if(e.key==='Enter')browseDir()});
+40 -3
View File
@@ -211,11 +211,17 @@
try {
const msg = JSON.parse(e.data);
if (msg.type === 'ping') { _ws.send('pong'); return; }
// Refresh stats + recent syncs on any real-time event
loadDashboard();
loadSyncs();
if (msg.type === 'alert') {
_addAlert('🔴', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
_addAlert('\u{1F534}', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
_playAlertSound();
const el = document.getElementById('statAlerts');
el.classList.add('animate-pulse');
setTimeout(() => el.classList.remove('animate-pulse'), 3000);
} else if (msg.type === 'recovery') {
_addAlert('🟢', `${msg.server_name} ${msg.metric} 恢复正常 (${msg.value.toFixed(1)}%)`);
_addAlert('\u{1F7E2}', `${msg.server_name} ${msg.metric} 恢复正常 (${msg.value.toFixed(1)}%)`);
} else if (msg.type === 'system') {
_addAlert('⚠️', msg.message);
}
@@ -240,7 +246,7 @@
document.getElementById('alertBanner').classList.remove('hidden');
document.getElementById('noAlertBanner').classList.add('hidden');
document.getElementById('alertList').innerHTML = _alerts.map(a =>
`<div class="text-slate-300">${a.icon} ${a.text} <span class="text-slate-600 text-xs float-right">${fmtTimeObj(a.time)}</span></div>`
`<div class="text-slate-300">${a.icon} ${esc(a.text)} <span class="text-slate-600 text-xs float-right">${fmtTimeObj(a.time)}</span></div>`
).join('');
}
@@ -250,6 +256,37 @@
document.getElementById('noAlertBanner').classList.remove('hidden');
}
// Alert sound using Web Audio API — no external file needed
function _playAlertSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.value = 0.15;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.stop(ctx.currentTime + 0.3);
} catch(e) {}
// Flash tab title
_flashTitle();
}
let _titleFlashTimer = null;
function _flashTitle() {
const orig = document.title;
let count = 0;
clearInterval(_titleFlashTimer);
_titleFlashTimer = setInterval(() => {
document.title = count % 2 === 0 ? '🔴 告警 — Nexus' : orig;
count++;
if (count > 10) { clearInterval(_titleFlashTimer); document.title = orig; }
}, 800);
}
function refreshAll() {
loadDashboard();
loadActivity();
+58 -1
View File
@@ -3,10 +3,11 @@
// Sets sidebar, highlights active nav item, loads user info.
function initLayout(activeNav) {
// activeNav: 'index'|'servers'|'files'|'push'|'scripts'|'credentials'|'schedules'|'retries'|'audit'|'settings'
// activeNav: 'index'|'servers'|'files'|'push'|'scripts'|'credentials'|'schedules'|'retries'|'commands'|'audit'|'settings'
const navItems = [
{ id:'index', icon:'🏠', label:'仪表盘', href:'/app/index.html' },
{ id:'servers', icon:'🖥', label:'服务器', href:'/app/servers.html' },
{ id:'assets', icon:'🗂', label:'资产管理', href:'/app/assets.html' },
{ id:'files', icon:'📁', label:'文件管理', href:'/app/files.html' },
{ id:'push', icon:'📤', label:'推送', href:'/app/push.html' },
{ id:'scripts', icon:'📜', label:'脚本库', href:'/app/scripts.html' },
@@ -16,6 +17,7 @@ function initLayout(activeNav) {
];
const bottomNav = [
{ id:'commands', icon:'💻', label:'命令日志', href:'/app/commands.html' },
{ id:'audit', icon:'📋', label:'审计日志', href:'/app/audit.html' },
{ id:'settings', icon:'⚙️', label:'设置', href:'/app/settings.html' },
];
@@ -33,6 +35,9 @@ function initLayout(activeNav) {
const sidebar = document.querySelector('[data-sidebar]');
if (!sidebar) return;
// Responsive: on mobile (<768px), sidebar defaults to hidden and overlays
const isMobile = window.innerWidth < 768;
sidebar.innerHTML = `
<div class="px-5 py-4 border-b border-slate-800">
<div class="flex items-center gap-3">
@@ -64,6 +69,58 @@ function initLayout(activeNav) {
// Load user info
loadLayoutUser();
// ── Mobile Responsive ──
// On mobile: collapse sidebar by default, add overlay + backdrop
if (isMobile) {
// Find Alpine component and set sidebarOpen = false
const alpineRoot = document.querySelector('[x-data]');
if (alpineRoot && alpineRoot.__x) {
alpineRoot.__x.$data.sidebarOpen = false;
} else {
// Direct DOM fallback
sidebar.style.display = 'none';
}
// Make sidebar overlay on mobile instead of pushing content
sidebar.style.position = 'fixed';
sidebar.style.top = '0';
sidebar.style.left = '0';
sidebar.style.bottom = '0';
sidebar.style.zIndex = '40';
// Add backdrop
let backdrop = document.getElementById('sidebarBackdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.id = 'sidebarBackdrop';
backdrop.className = 'fixed inset-0 bg-black/50 z-30 hidden';
backdrop.onclick = () => {
const root = document.querySelector('[x-data]');
if (root && root.__x) root.__x.$data.sidebarOpen = false;
sidebar.style.display = 'none';
backdrop.classList.add('hidden');
};
document.body.appendChild(backdrop);
}
// Watch for sidebar toggle to show/hide backdrop
const origToggle = document.querySelector('[x-data]');
if (origToggle) {
const observer = new MutationObserver(() => {
const isVisible = sidebar.style.display !== 'none' && sidebar.offsetParent !== null;
backdrop.classList.toggle('hidden', !isVisible);
});
observer.observe(sidebar, { attributes: true, attributeFilter: ['style'] });
}
}
// Make tables horizontally scrollable on all pages
document.querySelectorAll('table').forEach(table => {
if (!table.parentElement.classList.contains('overflow-x-auto')) {
const wrapper = document.createElement('div');
wrapper.className = 'overflow-x-auto';
table.parentNode.insertBefore(wrapper, table);
wrapper.appendChild(table);
}
});
}
async function loadLayoutUser() {
+52 -12
View File
@@ -80,13 +80,15 @@
</div>
<!-- Add/Edit Server Modal -->
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 overflow-y-auto py-8">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3 my-auto">
<h2 id="modalTitle" class="text-white font-semibold text-lg">添加服务器</h2>
<input type="hidden" id="editServerId">
<!-- Basic -->
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">基本</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="srvName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="web-server-01"></div>
<div><label class="block text-slate-400 text-xs mb-1">地址</label><input id="srvDomain" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
<div><label class="block text-slate-400 text-xs mb-1">名称 *</label><input id="srvName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="web-server-01"></div>
<div><label class="block text-slate-400 text-xs mb-1">地址 *</label><input id="srvDomain" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
</div>
<div class="grid grid-cols-3 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">SSH端口</label><input id="srvPort" type="number" value="22" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
@@ -94,11 +96,25 @@
<div><label class="block text-slate-400 text-xs mb-1">认证方式</label><select id="srvAuth" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="key">密钥</option><option value="password">密码</option></select></div>
</div>
<div id="srvPasswordRow"><label class="block text-slate-400 text-xs mb-1">密码</label><input id="srvPassword" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="SSH密码"></div>
<div id="srvKeyPathRow"><label class="block text-slate-400 text-xs mb-1">密钥路径</label><input id="srvKeyPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/root/.ssh/id_rsa"></div>
<!-- Agent -->
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">Agent</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">分类</label><input id="srvCategory" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="production"></div>
<div><label class="block text-slate-400 text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/target"></div>
<div><label class="block text-slate-400 text-xs mb-1">Agent端口</label><input id="srvAgentPort" type="number" value="8601" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div><label class="block text-slate-400 text-xs mb-1">Agent API Key</label><div class="flex items-center gap-2"><input id="srvAgentKey" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm text-xs" placeholder="留空则使用全局API Key"><button type="button" onclick="generateAgentKey()" class="px-2 py-2 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg shrink-0 transition" title="自动生成密钥">🔑</button></div></div>
</div>
<!-- Organization -->
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">组织</div>
<div class="grid grid-cols-3 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">分类</label><input id="srvCategory" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="production"></div>
<div><label class="block text-slate-400 text-xs mb-1">平台</label><select id="srvPlatform" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 无 —</option></select></div>
<div><label class="block text-slate-400 text-xs mb-1">节点</label><select id="srvNode" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 无 —</option></select></div>
</div>
<!-- Other -->
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/target"></div>
<div><label class="block text-slate-400 text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
</div>
<div><label class="block text-slate-400 text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
<div class="flex gap-2 pt-2"><button onclick="saveServer()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideServerModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
@@ -240,20 +256,44 @@
}
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;const r=await apiFetch(API+'/servers/'+id,{method:'DELETE'});if(r&&r.ok)toast('success','服务器已删除');else toast('error','删除失败');if(selectedServerId===id)hideDetail();loadServers()}
function showAddServer(){document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';document.getElementById('serverModal').classList.remove('hidden')}
function showEditServer(id){const s=_serversCache[id];if(!s)return;document.getElementById('editServerId').value=s.id;document.getElementById('modalTitle').textContent='编辑服务器';document.getElementById('srvName').value=s.name||'';document.getElementById('srvDomain').value=s.domain||'';document.getElementById('srvPort').value=s.port||22;document.getElementById('srvUser').value=s.username||'root';document.getElementById('srvAuth').value=s.auth_method||'key';document.getElementById('srvPassword').value='';document.getElementById('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';document.getElementById('serverModal').classList.remove('hidden')}
function showAddServer(){document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc','srvKeyPath','srvAgentKey'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';document.getElementById('srvAgentPort').value='8601';document.getElementById('srvPlatform').value='';document.getElementById('srvNode').value='';toggleAuthFields();loadOrgSelects();document.getElementById('serverModal').classList.remove('hidden')}
function showEditServer(id){const s=_serversCache[id];if(!s)return;document.getElementById('editServerId').value=s.id;document.getElementById('modalTitle').textContent='编辑服务器';document.getElementById('srvName').value=s.name||'';document.getElementById('srvDomain').value=s.domain||'';document.getElementById('srvPort').value=s.port||22;document.getElementById('srvUser').value=s.username||'root';document.getElementById('srvAuth').value=s.auth_method||'key';document.getElementById('srvPassword').value='';document.getElementById('srvKeyPath').value=s.ssh_key_path||'';document.getElementById('srvAgentPort').value=s.agent_port||8601;document.getElementById('srvAgentKey').value=s.agent_api_key||'';document.getElementById('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';toggleAuthFields();loadOrgSelects().then(()=>{document.getElementById('srvPlatform').value=s.platform_id||'';document.getElementById('srvNode').value=s.node_id||''});document.getElementById('serverModal').classList.remove('hidden')}
function hideServerModal(){document.getElementById('serverModal').classList.add('hidden')}
document.getElementById('srvAuth').addEventListener('change',()=>{document.getElementById('srvPasswordRow').style.display=document.getElementById('srvAuth').value==='password'?'':'none'});
function toggleAuthFields(){const isKey=document.getElementById('srvAuth').value==='key';document.getElementById('srvPasswordRow').style.display=isKey?'none':'';document.getElementById('srvKeyPathRow').style.display=isKey?'':'none'}
document.getElementById('srvAuth').addEventListener('change',toggleAuthFields);
async function loadOrgSelects(){
try{
const [pr,nr]=await Promise.all([apiFetch(API+'/assets/platforms'),apiFetch(API+'/assets/nodes')]);
if(pr){const plats=await pr.json();document.getElementById('srvPlatform').innerHTML='<option value="">— 无 —</option>'+plats.map(p=>`<option value="${p.id}">${esc(p.name)}</option>`).join('')}
if(nr){const nodes=await nr.json();document.getElementById('srvNode').innerHTML='<option value="">— 无 —</option>'+nodes.map(n=>`<option value="${n.id}">${esc(n.name)}</option>`).join('')}
}catch(e){}
}
async function saveServer(){
const id=document.getElementById('editServerId').value;
const body={name:document.getElementById('srvName').value,domain:document.getElementById('srvDomain').value,port:parseInt(document.getElementById('srvPort').value)||22,username:document.getElementById('srvUser').value||'root',auth_method:document.getElementById('srvAuth').value,category:document.getElementById('srvCategory').value||null,target_path:document.getElementById('srvTarget').value||null,description:document.getElementById('srvDesc').value||null};
const body={name:document.getElementById('srvName').value,domain:document.getElementById('srvDomain').value,port:parseInt(document.getElementById('srvPort').value)||22,username:document.getElementById('srvUser').value||'root',auth_method:document.getElementById('srvAuth').value,agent_port:parseInt(document.getElementById('srvAgentPort').value)||8601,category:document.getElementById('srvCategory').value||null,target_path:document.getElementById('srvTarget').value||null,description:document.getElementById('srvDesc').value||null,platform_id:document.getElementById('srvPlatform').value?parseInt(document.getElementById('srvPlatform').value):null,node_id:document.getElementById('srvNode').value?parseInt(document.getElementById('srvNode').value):null};
if(body.auth_method==='password'){body.password=document.getElementById('srvPassword').value}
if(!body.name||!body.domain){alert('名称和地址必填');return}
else{body.ssh_key_path=document.getElementById('srvKeyPath').value||null}
const ak=document.getElementById('srvAgentKey').value;if(ak)body.agent_api_key=ak;
if(!body.name||!body.domain){toast('warning','名称和地址必填');return}
if(id){const r=await apiFetch(API+'/servers/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','服务器已更新');else toast('error','更新失败')}else{const r=await apiFetch(API+'/servers/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','服务器已添加');else toast('error','添加失败')}
hideServerModal();loadServers();
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
// ── Generate Agent API Key ──
async function generateAgentKey(){
const id=document.getElementById('editServerId').value;
if(!id){toast('warning','请先保存服务器,再生成Agent密钥');return}
if(!confirm('将为该服务器生成新的Agent API Key,旧密钥将失效。继续?'))return;
try{
const r=await apiFetch(API+'/servers/'+id+'/agent-key',{method:'POST',headers:apiHeadersJSON()});
if(!r)return;
const data=await r.json();
document.getElementById('srvAgentKey').value=data.agent_api_key||'';
toast('success','Agent API Key已生成,请保存服务器以应用');
}catch(e){toast('error','生成密钥失败')}
}
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
document.getElementById('searchInput').addEventListener('input',()=>{
+309 -7
View File
@@ -1,29 +1,100 @@
<!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="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.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}">
<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">
<div class="mb-6 bg-slate-900 rounded-xl border border-slate-800 p-4">
<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">
<div class="flex items-center justify-between mb-3">
<div>
<span class="text-white text-sm font-medium">双因素认证 (TOTP)</span>
<span id="totpStatusBadge" class="ml-2 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">检测中...</span>
</div>
</div>
<!-- TOTP: Disabled state — show setup button -->
<div id="totpSetupArea">
<p class="text-slate-400 text-xs mb-3">启用双因素认证后,登录时需要输入验证器App生成的一次性验证码。</p>
<div id="totpIdleActions">
<button onclick="startTotpSetup()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">启用 TOTP</button>
</div>
<!-- TOTP Step 1: Show secret + QR -->
<div id="totpStep1" class="hidden space-y-3">
<div class="bg-slate-800 rounded-lg p-4 text-center">
<p class="text-slate-400 text-xs mb-2">使用验证器App扫描以下二维码或手动输入密钥</p>
<canvas id="totpQrCanvas" class="mx-auto rounded border border-slate-700" width="200" height="200"></canvas>
</div>
<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-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all"></code>
<button onclick="copyTotpSecret()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
</div>
</div>
<div>
<label class="block text-slate-400 text-xs mb-1">输入验证码确认启用</label>
<div class="flex items-center gap-2">
<input id="totpVerifyCode" maxlength="6" placeholder="6位数字" class="w-32 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono text-center tracking-widest">
<button onclick="verifyAndEnableTotp()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">确认启用</button>
<button onclick="cancelTotpSetup()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">取消</button>
</div>
</div>
</div>
</div>
<!-- TOTP: Enabled state — show disable button -->
<div id="totpEnabledArea" class="hidden">
<p class="text-slate-400 text-xs mb-3">双因素认证已启用。禁用后登录将不再需要验证码。</p>
<button onclick="disableTotp()" class="px-4 py-2 bg-red-900/50 hover:bg-red-900 text-red-300 text-sm rounded-lg transition">禁用 TOTP</button>
</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 mb-4">
<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 mb-4">
<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 mb-4">
<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>
@@ -34,11 +105,11 @@
<div id="telegramSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<div id="saveMsg" class="hidden mt-4 text-center text-green-400 text-sm">已保存</div>
</main>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
<script>
initLayout('settings');
const SECTIONS={
@@ -48,7 +119,11 @@
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;
@@ -73,16 +148,243 @@
</div>`;
}).join('');
}
// API Key section (read-only + copy)
renderApiKeySection();
}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()==='显示'){
try{
const r=await apiFetch(API+'/settings/api-key/reveal',{method:'POST'});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 ──
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 setupArea=document.getElementById('totpSetupArea');
const enabledArea=document.getElementById('totpEnabledArea');
if(enabled){
badge.textContent='已启用';
badge.className='ml-2 text-xs px-2 py-0.5 rounded-full bg-green-900/50 text-green-400';
setupArea.classList.add('hidden');
enabledArea.classList.remove('hidden');
}else{
badge.textContent='未启用';
badge.className='ml-2 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500';
setupArea.classList.remove('hidden');
enabledArea.classList.add('hidden');
resetTotpSteps();
}
}
function resetTotpSteps(){
document.getElementById('totpIdleActions').classList.remove('hidden');
document.getElementById('totpStep1').classList.add('hidden');
document.getElementById('totpVerifyCode').value='';
}
async function startTotpSetup(){
if(!_currentAdminId){toast('error','未获取用户信息');return}
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;
// Show secret
document.getElementById('totpSecretDisplay').textContent=data.secret;
document.getElementById('totpIdleActions').classList.add('hidden');
document.getElementById('totpStep1').classList.remove('hidden');
// Generate QR code
try{
new QRious({
element:document.getElementById('totpQrCanvas'),
value:data.uri,
size:200,
backgroundAlpha:0,
foreground:'#e2e8f0',
level:'M'
});
}catch(e){
// QRious not loaded — fallback to text display
document.getElementById('totpQrCanvas').style.display='none';
}
}catch(e){toast('error','TOTP设置请求失败')}
}
async function verifyAndEnableTotp(){
const code=document.getElementById('totpVerifyCode').value.trim();
if(code.length!==6||!/^\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','TOTP已启用!下次登录需要验证码');
updateTotpUI(true);
}else{
toast('error',data.message||data.reason||'验证码错误');
}
}catch(e){toast('error','启用失败')}
}
async function disableTotp(){
if(!confirm('确定要禁用双因素认证?禁用后登录将不再需要验证码。'))return;
try{
const r=await apiFetch(API+'/auth/totp/disable',{
method:'POST',headers:apiHeadersJSON(),
body:JSON.stringify({admin_id:_currentAdminId})
});
if(!r)return;
const data=await r.json();
if(data.success){
toast('success','TOTP已禁用');
updateTotpUI(false);
}else{
toast('error','禁用失败');
}
}catch(e){toast('error','禁用请求失败')}
}
function cancelTotpSetup(){
resetTotpSteps();
}
// ── 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}
// ── Init ──
loadSettings();
loadTotpStatus();
</script>
</body></html>
+2
View File
@@ -122,6 +122,8 @@
if (ev.code === 4001) {
term.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n');
setTimeout(() => location.href = '/app/login.html', 2000);
} else if (ev.code === 4003) {
term.write('\r\n\x1b[31m⚠ 授权失败: ' + (ev.reason || '服务器SSH凭据未配置') + '\x1b[0m\r\n');
} else if (ev.code === 4004) {
term.write('\r\n\x1b[31m⚠ 服务器不存在\x1b[0m\r\n');
} else if (ev.code !== 1000) {