fix: 主题系统改用CSS变量 — dark:前缀在浏览器CDN不生效
根因: Tailwind v4 浏览器CDN(tailwindcss-browser.js)不处理外部CSS的
@custom-variant指令, dark:前缀规则完全不生成
方案: 改用CSS变量 + :root.dark {} 选择器
- theme.css: :root 定义亮色值, :root.dark 定义暗色值
- 15个HTML页面: 恢复 bg-[var(--bg-page)] 等CSS变量class
- 移除所有 dark: 前缀class(CSS变量自动适配)
- theme-init.js: 同步读localStorage设.dark class(FOUC防护)
- layout.js: toggleTheme 切换 .dark class + localStorage + MySQL
优势: CSS变量方案不依赖Tailwind dark:变体, 100%兼容浏览器CDN
This commit is contained in:
+177
-177
@@ -2,191 +2,191 @@
|
||||
<script src="/app/vendor/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
|
||||
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<select id="serverFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部服务器</option>
|
||||
</select>
|
||||
<select id="typeFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部类型</option>
|
||||
<option value="cpu">CPU</option>
|
||||
<option value="mem">内存</option>
|
||||
<option value="disk">磁盘</option>
|
||||
<option value="time_drift">时钟偏差</option>
|
||||
<option value="system">系统</option>
|
||||
</select>
|
||||
<select id="kindFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">告警+恢复</option>
|
||||
<option value="alert">仅告警</option>
|
||||
<option value="recovery">仅恢复</option>
|
||||
</select>
|
||||
<input id="dateFrom" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<span class="text-[var(--text-dim)] text-xs">至</span>
|
||||
<input id="dateTo" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<button onclick="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div id="statsSection" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"></div>
|
||||
|
||||
<!-- Top alerting servers -->
|
||||
<div id="topServersSection" class="hidden bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<h2 class="text-white font-medium text-sm mb-3">告警最多的服务器(近 7 天)</h2>
|
||||
<div id="topServersList" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Alert list -->
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[700px]">
|
||||
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">状态</th>
|
||||
<th class="text-left px-4 py-3">类型</th>
|
||||
<th class="text-left px-4 py-3">服务器</th>
|
||||
<th class="text-left px-4 py-3">数值</th>
|
||||
<th class="text-right px-4 py-3">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="alertsTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="hidden flex items-center justify-between text-sm">
|
||||
<span id="pageInfo" class="text-[var(--text-muted)] text-xs"></span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('alerts');
|
||||
const PAGE_SIZE = 50;
|
||||
let _offset = 0, _total = 0;
|
||||
|
||||
const TYPE_LABEL = {cpu:'CPU',mem:'内存',disk:'磁盘',time_drift:'时钟偏差',system:'系统'};
|
||||
const TYPE_COLOR = {cpu:'text-orange-400',mem:'text-blue-400',disk:'text-yellow-400',time_drift:'text-purple-400',system:'text-[var(--text-secondary)]'};
|
||||
|
||||
async function loadServers(){
|
||||
const r=await apiFetch(API+'/servers/?per_page=200');if(!r)return;
|
||||
const data=await r.json();
|
||||
const sel=document.getElementById('serverFilter');
|
||||
(data.items||[]).forEach(s=>{const o=document.createElement('option');o.value=s.id;o.textContent=s.name;sel.appendChild(o)});
|
||||
}
|
||||
|
||||
async function loadStats(){
|
||||
const r=await apiFetch(API+'/alert-history/stats?days=7');if(!r)return;
|
||||
const d=await r.json();
|
||||
// Top servers
|
||||
if(d.top_servers&&d.top_servers.length){
|
||||
document.getElementById('topServersSection').classList.remove('hidden');
|
||||
document.getElementById('topServersList').innerHTML=d.top_servers.map((s,i)=>`
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 text-[var(--text-dim)] text-xs text-right">${i+1}</span>
|
||||
<div class="flex-1 bg-[var(--bg-surface)] rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full bg-red-500/70 rounded-full transition-all" style="width:${Math.round(s.count/d.top_servers[0].count*100)}%"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<select id="serverFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部服务器</option>
|
||||
</select>
|
||||
<select id="typeFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部类型</option>
|
||||
<option value="cpu">CPU</option>
|
||||
<option value="mem">内存</option>
|
||||
<option value="disk">磁盘</option>
|
||||
<option value="time_drift">时钟偏差</option>
|
||||
<option value="system">系统</option>
|
||||
</select>
|
||||
<select id="kindFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<option value="">告警+恢复</option>
|
||||
<option value="alert">仅告警</option>
|
||||
<option value="recovery">仅恢复</option>
|
||||
</select>
|
||||
<input id="dateFrom" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<span class="text-gray-300 dark:text-gray-600 text-xs">至</span>
|
||||
<input id="dateTo" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<button onclick="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<span class="text-slate-300 text-xs w-32 truncate">${esc(s.server_name)}</span>
|
||||
<span class="text-red-400 text-xs font-medium w-12 text-right">${s.count} 次</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
// Stats cards
|
||||
const totalAlerts=(d.daily||[]).reduce((s,r)=>s+r.alerts,0);
|
||||
const totalRecoveries=(d.daily||[]).reduce((s,r)=>s+r.recoveries,0);
|
||||
document.getElementById('statsSection').innerHTML=`
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="text-[var(--text-secondary)] text-xs mb-1">近 7 天告警次数</div>
|
||||
<div class="text-red-400 text-3xl font-bold">${totalAlerts}</div>
|
||||
</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="text-[var(--text-secondary)] text-xs mb-1">近 7 天恢复次数</div>
|
||||
<div class="text-green-400 text-3xl font-bold">${totalRecoveries}</div>
|
||||
</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="text-[var(--text-secondary)] text-xs mb-1">告警最多的类型</div>
|
||||
<div class="text-white text-xl font-bold">${getTopType(d)}</div>
|
||||
</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="text-[var(--text-secondary)] text-xs mb-1">告警最多的服务器</div>
|
||||
<div class="text-white text-base font-bold truncate">${(d.top_servers||[])[0]?.server_name||'--'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div id="statsSection" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"></div>
|
||||
function getTopType(d){
|
||||
if(!d.top_types||!d.top_types.length)return '--';
|
||||
const t=d.top_types[0];
|
||||
const labels={'cpu':'CPU','memory':'内存','disk':'磁盘','offline':'离线','agent':'Agent'};
|
||||
const label=labels[t.alert_type]||t.alert_type;
|
||||
return label+' ('+t.count+'次)';
|
||||
}
|
||||
|
||||
<!-- Top alerting servers -->
|
||||
<div id="topServersSection" class="hidden bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<h2 class="text-white font-medium text-sm mb-3">告警最多的服务器(近 7 天)</h2>
|
||||
<div id="topServersList" class="space-y-2"></div>
|
||||
</div>
|
||||
async function loadAlerts(){
|
||||
const serverId=document.getElementById('serverFilter').value;
|
||||
const alertType=document.getElementById('typeFilter').value;
|
||||
const kind=document.getElementById('kindFilter').value;
|
||||
const dateFrom=document.getElementById('dateFrom').value;
|
||||
const dateTo=document.getElementById('dateTo').value;
|
||||
|
||||
<!-- Alert list -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[700px]">
|
||||
<thead class="bg-slate-800/50 text-gray-500 dark:text-gray-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">状态</th>
|
||||
<th class="text-left px-4 py-3">类型</th>
|
||||
<th class="text-left px-4 py-3">服务器</th>
|
||||
<th class="text-left px-4 py-3">数值</th>
|
||||
<th class="text-right px-4 py-3">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="alertsTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
let url=`${API}/alert-history/?limit=${PAGE_SIZE}&offset=${_offset}`;
|
||||
if(serverId)url+='&server_id='+serverId;
|
||||
if(alertType)url+='&alert_type='+alertType;
|
||||
if(kind==='alert')url+='&is_recovery=false';
|
||||
if(kind==='recovery')url+='&is_recovery=true';
|
||||
if(dateFrom)url+='&date_from='+encodeURIComponent(dateFrom);
|
||||
if(dateTo)url+='&date_to='+encodeURIComponent(dateTo+'T23:59:59');
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="hidden flex items-center justify-between text-sm">
|
||||
<span id="pageInfo" class="text-gray-400 dark:text-gray-500 text-xs"></span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('alerts');
|
||||
const PAGE_SIZE = 50;
|
||||
let _offset = 0, _total = 0;
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const data=await r.json();
|
||||
_total=data.total||0;
|
||||
const logs=data.items||[];
|
||||
const tbody=document.getElementById('alertsTbody');
|
||||
|
||||
const TYPE_LABEL = {cpu:'CPU',mem:'内存',disk:'磁盘',time_drift:'时钟偏差',system:'系统'};
|
||||
const TYPE_COLOR = {cpu:'text-orange-400',mem:'text-blue-400',disk:'text-yellow-400',time_drift:'text-purple-400',system:'text-gray-500 dark:text-gray-400'};
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无告警记录</td></tr>';document.getElementById('pagination').classList.add('hidden');return}
|
||||
|
||||
async function loadServers(){
|
||||
const r=await apiFetch(API+'/servers/?per_page=200');if(!r)return;
|
||||
const data=await r.json();
|
||||
const sel=document.getElementById('serverFilter');
|
||||
(data.items||[]).forEach(s=>{const o=document.createElement('option');o.value=s.id;o.textContent=s.name;sel.appendChild(o)});
|
||||
}
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const isRec=l.is_recovery;
|
||||
const statusBadge=isRec
|
||||
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/30 text-green-400 border border-green-500/30">🟢 恢复</span>'
|
||||
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-900/30 text-red-400 border border-red-500/30">🔴 告警</span>';
|
||||
const typeColor=TYPE_COLOR[l.alert_type]||'text-[var(--text-secondary)]';
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5">${statusBadge}</td>
|
||||
<td class="px-4 py-2.5 ${typeColor} text-xs font-medium">${esc(TYPE_LABEL[l.alert_type]||l.alert_type)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-300 text-xs">${esc(l.server_name||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs font-mono">${esc(l.value||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
async function loadStats(){
|
||||
const r=await apiFetch(API+'/alert-history/stats?days=7');if(!r)return;
|
||||
const d=await r.json();
|
||||
// Top servers
|
||||
if(d.top_servers&&d.top_servers.length){
|
||||
document.getElementById('topServersSection').classList.remove('hidden');
|
||||
document.getElementById('topServersList').innerHTML=d.top_servers.map((s,i)=>`
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 text-gray-300 dark:text-gray-600 text-xs text-right">${i+1}</span>
|
||||
<div class="flex-1 bg-gray-100 dark:bg-gray-800 rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full bg-red-500/70 rounded-full transition-all" style="width:${Math.round(s.count/d.top_servers[0].count*100)}%"></div>
|
||||
</div>
|
||||
<span class="text-slate-300 text-xs w-32 truncate">${esc(s.server_name)}</span>
|
||||
<span class="text-red-400 text-xs font-medium w-12 text-right">${s.count} 次</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
// Stats cards
|
||||
const totalAlerts=(d.daily||[]).reduce((s,r)=>s+r.alerts,0);
|
||||
const totalRecoveries=(d.daily||[]).reduce((s,r)=>s+r.recoveries,0);
|
||||
document.getElementById('statsSection').innerHTML=`
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs mb-1">近 7 天告警次数</div>
|
||||
<div class="text-red-400 text-3xl font-bold">${totalAlerts}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs mb-1">近 7 天恢复次数</div>
|
||||
<div class="text-green-400 text-3xl font-bold">${totalRecoveries}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs mb-1">告警最多的类型</div>
|
||||
<div class="text-white text-xl font-bold">${getTopType(d)}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs mb-1">告警最多的服务器</div>
|
||||
<div class="text-white text-base font-bold truncate">${(d.top_servers||[])[0]?.server_name||'--'}</div>
|
||||
</div>`;
|
||||
}
|
||||
const pg=document.getElementById('pagination');
|
||||
pg.classList.remove('hidden');
|
||||
const curPage=Math.floor(_offset/PAGE_SIZE)+1;
|
||||
const totalPages=Math.max(1,Math.ceil(_total/PAGE_SIZE));
|
||||
document.getElementById('pageInfo').textContent=`共 ${_total} 条 · 第 ${curPage}/${totalPages} 页`;
|
||||
document.getElementById('prevBtn').disabled=_offset===0;
|
||||
document.getElementById('nextBtn').disabled=_offset+PAGE_SIZE>=_total;
|
||||
}
|
||||
|
||||
function getTopType(d){
|
||||
if(!d.top_types||!d.top_types.length)return '--';
|
||||
const t=d.top_types[0];
|
||||
const labels={'cpu':'CPU','memory':'内存','disk':'磁盘','offline':'离线','agent':'Agent'};
|
||||
const label=labels[t.alert_type]||t.alert_type;
|
||||
return label+' ('+t.count+'次)';
|
||||
}
|
||||
function prevPage(){_offset=Math.max(0,_offset-PAGE_SIZE);loadAlerts()}
|
||||
function nextPage(){_offset+=PAGE_SIZE;loadAlerts()}
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmt(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
|
||||
|
||||
async function loadAlerts(){
|
||||
const serverId=document.getElementById('serverFilter').value;
|
||||
const alertType=document.getElementById('typeFilter').value;
|
||||
const kind=document.getElementById('kindFilter').value;
|
||||
const dateFrom=document.getElementById('dateFrom').value;
|
||||
const dateTo=document.getElementById('dateTo').value;
|
||||
|
||||
let url=`${API}/alert-history/?limit=${PAGE_SIZE}&offset=${_offset}`;
|
||||
if(serverId)url+='&server_id='+serverId;
|
||||
if(alertType)url+='&alert_type='+alertType;
|
||||
if(kind==='alert')url+='&is_recovery=false';
|
||||
if(kind==='recovery')url+='&is_recovery=true';
|
||||
if(dateFrom)url+='&date_from='+encodeURIComponent(dateFrom);
|
||||
if(dateTo)url+='&date_to='+encodeURIComponent(dateTo+'T23:59:59');
|
||||
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const data=await r.json();
|
||||
_total=data.total||0;
|
||||
const logs=data.items||[];
|
||||
const tbody=document.getElementById('alertsTbody');
|
||||
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">暂无告警记录</td></tr>';document.getElementById('pagination').classList.add('hidden');return}
|
||||
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const isRec=l.is_recovery;
|
||||
const statusBadge=isRec
|
||||
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/30 text-green-400 border border-green-500/30">🟢 恢复</span>'
|
||||
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-900/30 text-red-400 border border-red-500/30">🔴 告警</span>';
|
||||
const typeColor=TYPE_COLOR[l.alert_type]||'text-gray-500 dark:text-gray-400';
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5">${statusBadge}</td>
|
||||
<td class="px-4 py-2.5 ${typeColor} text-xs font-medium">${esc(TYPE_LABEL[l.alert_type]||l.alert_type)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-300 text-xs">${esc(l.server_name||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs font-mono">${esc(l.value||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const pg=document.getElementById('pagination');
|
||||
pg.classList.remove('hidden');
|
||||
const curPage=Math.floor(_offset/PAGE_SIZE)+1;
|
||||
const totalPages=Math.max(1,Math.ceil(_total/PAGE_SIZE));
|
||||
document.getElementById('pageInfo').textContent=`共 ${_total} 条 · 第 ${curPage}/${totalPages} 页`;
|
||||
document.getElementById('prevBtn').disabled=_offset===0;
|
||||
document.getElementById('nextBtn').disabled=_offset+PAGE_SIZE>=_total;
|
||||
}
|
||||
|
||||
function prevPage(){_offset=Math.max(0,_offset-PAGE_SIZE);loadAlerts()}
|
||||
function nextPage(){_offset+=PAGE_SIZE;loadAlerts()}
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmt(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
|
||||
|
||||
Promise.all([loadServers(),loadStats()]).then(()=>loadAlerts());
|
||||
</script>
|
||||
Promise.all([loadServers(),loadStats()]).then(()=>loadAlerts());
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+169
-169
@@ -2,179 +2,179 @@
|
||||
<script src="/app/vendor/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<select id="actionFilter" onchange="_offset=0;loadAudit()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部操作</option>
|
||||
<optgroup label="服务器">
|
||||
<option value="create_server">添加服务器</option>
|
||||
<option value="update_server">更新服务器</option>
|
||||
<option value="delete_server">删除服务器</option>
|
||||
<option value="install_agent">安装 Agent</option>
|
||||
<option value="generate_agent_key">生成 Agent Key</option>
|
||||
</optgroup>
|
||||
<optgroup label="推送 / 同步">
|
||||
<option value="sync_files">文件推送</option>
|
||||
<option value="sync_preview">推送预览(dry-run)</option>
|
||||
<option value="sync_commands">命令批量执行</option>
|
||||
<option value="file_delete">远程文件删除</option>
|
||||
<option value="file_rename">远程文件重命名</option>
|
||||
<option value="file_mkdir">远程新建目录</option>
|
||||
<option value="browse_directory">目录浏览</option>
|
||||
</optgroup>
|
||||
<optgroup label="脚本执行">
|
||||
<option value="execute_started">脚本执行开始</option>
|
||||
<option value="execute_command">脚本执行命令</option>
|
||||
<option value="stop_execution">停止脚本执行</option>
|
||||
<option value="retry_execution">重试脚本执行</option>
|
||||
<option value="mark_stuck">标记脚本卡住</option>
|
||||
<option value="auto_stuck">自动检测卡住</option>
|
||||
<option value="server_auto_stuck">单台服务器卡住</option>
|
||||
<option value="script_job_callback">长任务回调</option>
|
||||
</optgroup>
|
||||
<optgroup label="认证 / 安全">
|
||||
<option value="login_success">登录成功</option>
|
||||
<option value="login_locked">登录锁定</option>
|
||||
<option value="logout">登出</option>
|
||||
<option value="change_password">修改密码</option>
|
||||
<option value="setup_totp">TOTP 设置</option>
|
||||
<option value="enable_totp">启用 TOTP</option>
|
||||
<option value="disable_totp">禁用 TOTP</option>
|
||||
<option value="reveal_api_key">查看 API Key</option>
|
||||
<option value="refresh_reuse_attack">Token 重用攻击</option>
|
||||
</optgroup>
|
||||
<optgroup label="系统设置">
|
||||
<option value="update_setting">修改设置</option>
|
||||
<option value="create_schedule">新建调度</option>
|
||||
<option value="update_schedule">更新调度</option>
|
||||
<option value="delete_schedule">删除调度</option>
|
||||
<option value="retry_job">重试任务</option>
|
||||
<option value="delete_retry">删除重试</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<input id="adminFilter" type="text" placeholder="操作人" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm w-28 placeholder-slate-500">
|
||||
<input id="dateFrom" type="date" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<span class="text-gray-300 dark:text-gray-600 text-xs">至</span>
|
||||
<input id="dateTo" type="date" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<button onclick="clearFilters()" class="px-2 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-gray-500 dark:text-gray-400 text-xs rounded-lg transition" title="清除日期过滤">✕</button>
|
||||
<button onclick="_offset=0;loadAudit()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="auditTable" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-gray-500 dark:text-gray-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">操作人</th><th class="text-left px-4 py-3">操作</th><th class="text-left px-4 py-3">目标</th><th class="text-left px-4 py-3">详情</th><th class="text-left px-4 py-3">IP</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="auditTbody" class="divide-y divide-slate-800"><tr><td colspan="6" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="hidden mt-4 flex items-center justify-between">
|
||||
<span id="pageInfo" class="text-gray-400 dark:text-gray-500 text-sm"></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('audit');
|
||||
const PAGE_SIZE=50;
|
||||
let _offset=0;
|
||||
let _total=0;
|
||||
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<select id="actionFilter" onchange="_offset=0;loadAudit()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部操作</option>
|
||||
<optgroup label="服务器">
|
||||
<option value="create_server">添加服务器</option>
|
||||
<option value="update_server">更新服务器</option>
|
||||
<option value="delete_server">删除服务器</option>
|
||||
<option value="install_agent">安装 Agent</option>
|
||||
<option value="generate_agent_key">生成 Agent Key</option>
|
||||
</optgroup>
|
||||
<optgroup label="推送 / 同步">
|
||||
<option value="sync_files">文件推送</option>
|
||||
<option value="sync_preview">推送预览(dry-run)</option>
|
||||
<option value="sync_commands">命令批量执行</option>
|
||||
<option value="file_delete">远程文件删除</option>
|
||||
<option value="file_rename">远程文件重命名</option>
|
||||
<option value="file_mkdir">远程新建目录</option>
|
||||
<option value="browse_directory">目录浏览</option>
|
||||
</optgroup>
|
||||
<optgroup label="脚本执行">
|
||||
<option value="execute_started">脚本执行开始</option>
|
||||
<option value="execute_command">脚本执行命令</option>
|
||||
<option value="stop_execution">停止脚本执行</option>
|
||||
<option value="retry_execution">重试脚本执行</option>
|
||||
<option value="mark_stuck">标记脚本卡住</option>
|
||||
<option value="auto_stuck">自动检测卡住</option>
|
||||
<option value="server_auto_stuck">单台服务器卡住</option>
|
||||
<option value="script_job_callback">长任务回调</option>
|
||||
</optgroup>
|
||||
<optgroup label="认证 / 安全">
|
||||
<option value="login_success">登录成功</option>
|
||||
<option value="login_locked">登录锁定</option>
|
||||
<option value="logout">登出</option>
|
||||
<option value="change_password">修改密码</option>
|
||||
<option value="setup_totp">TOTP 设置</option>
|
||||
<option value="enable_totp">启用 TOTP</option>
|
||||
<option value="disable_totp">禁用 TOTP</option>
|
||||
<option value="reveal_api_key">查看 API Key</option>
|
||||
<option value="refresh_reuse_attack">Token 重用攻击</option>
|
||||
</optgroup>
|
||||
<optgroup label="系统设置">
|
||||
<option value="update_setting">修改设置</option>
|
||||
<option value="create_schedule">新建调度</option>
|
||||
<option value="update_schedule">更新调度</option>
|
||||
<option value="delete_schedule">删除调度</option>
|
||||
<option value="retry_job">重试任务</option>
|
||||
<option value="delete_retry">删除重试</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<input id="adminFilter" type="text" placeholder="操作人" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm w-28 placeholder-slate-500">
|
||||
<input id="dateFrom" type="date" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<span class="text-[var(--text-dim)] text-xs">至</span>
|
||||
<input id="dateTo" type="date" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<button onclick="clearFilters()" class="px-2 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded-lg transition" title="清除日期过滤">✕</button>
|
||||
<button onclick="_offset=0;loadAudit()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="auditTable" class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase"><tr><th class="text-left px-4 py-3">操作人</th><th class="text-left px-4 py-3">操作</th><th class="text-left px-4 py-3">目标</th><th class="text-left px-4 py-3">详情</th><th class="text-left px-4 py-3">IP</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="auditTbody" class="divide-y divide-slate-800"><tr><td colspan="6" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="hidden mt-4 flex items-center justify-between">
|
||||
<span id="pageInfo" class="text-[var(--text-muted)] text-sm"></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('audit');
|
||||
const PAGE_SIZE=50;
|
||||
let _offset=0;
|
||||
let _total=0;
|
||||
|
||||
const ACTION_NAMES={
|
||||
'create_server':'添加服务器','update_server':'更新服务器','delete_server':'删除服务器',
|
||||
'import_servers':'导入服务器','install_agent':'安装 Agent','upgrade_agent':'升级 Agent',
|
||||
'generate_agent_key':'生成 Agent Key','reveal_install_cmd':'查看安装命令',
|
||||
'batch_install_agent':'批量安装 Agent','batch_upgrade_agent':'批量升级 Agent',
|
||||
'sync_files':'文件推送','sync_preview':'推送预览','sync_commands':'批量命令',
|
||||
'file_delete':'远程删除','file_rename':'远程重命名','file_mkdir':'远程新建目录',
|
||||
'browse_directory':'目录浏览','file_upload':'文件上传',
|
||||
'create_credential':'添加凭据','update_credential':'更新凭据','delete_credential':'删除凭据',
|
||||
'create_script':'添加脚本','update_script':'更新脚本','delete_script':'删除脚本',
|
||||
'execute_started':'执行开始','execute_command':'执行命令','stop_execution':'停止执行',
|
||||
'retry_execution':'重试执行','mark_stuck':'标记卡住','auto_stuck':'自动卡住',
|
||||
'server_auto_stuck':'服务器卡住','script_job_callback':'长任务回调',
|
||||
'login_success':'登录成功','login_locked':'登录锁定','login_failed':'登录失败',
|
||||
'login_ip_blocked':'IP不在白名单',
|
||||
'logout':'登出','change_password':'修改密码',
|
||||
'setup_totp':'设置 TOTP','enable_totp':'启用 TOTP','disable_totp':'禁用 TOTP',
|
||||
'reveal_api_key':'查看 API Key','refresh_reuse_attack':'Token 重用攻击',
|
||||
'refresh_invalid_format':'刷新令牌格式无效','refresh_admin_not_found':'令牌引用管理员不存在',
|
||||
'refresh_token_mismatch':'Token 不匹配','refresh_legacy_not_found':'旧版刷新令牌未找到',
|
||||
'update_setting':'修改设置','create_schedule':'新建调度','update_schedule':'更新调度',
|
||||
'delete_schedule':'删除调度','retry_job':'重试任务','delete_retry':'删除重试',
|
||||
'create_preset':'添加密码预设','update_preset':'更新密码预设','delete_preset':'删除密码预设',
|
||||
'reveal_preset':'查看密码预设',
|
||||
'create_ssh_key_preset':'添加密钥预设','update_ssh_key_preset':'更新密钥预设',
|
||||
'delete_ssh_key_preset':'删除密钥预设','reveal_ssh_key_preset':'查看密钥预设',
|
||||
'webssh_connect':'WebSSH 连接','webssh_disconnect':'WebSSH 断开','webssh_token':'WebSSH 令牌',
|
||||
'refresh_token_mismatch':'Token 不匹配',
|
||||
'create_platform':'添加平台','update_platform':'更新平台','delete_platform':'删除平台',
|
||||
'create_node':'添加节点','update_node':'更新节点','delete_node':'删除节点',
|
||||
'parse_subscription':'解析订阅','update_ip_allowlist':'更新 IP 白名单','toggle_ip_allowlist':'切换 IP 白名单',
|
||||
};
|
||||
const TARGET_NAMES={'server':'服务器','credential':'凭据','db_credential':'数据库凭据','script':'脚本','script_execution':'脚本执行','setting':'设置','schedule':'调度','preset':'密码预设','ssh_key_preset':'密钥预设','platform':'平台','node':'节点','admin':'管理员','retry':'重试'};
|
||||
const ACTION_NAMES={
|
||||
'create_server':'添加服务器','update_server':'更新服务器','delete_server':'删除服务器',
|
||||
'import_servers':'导入服务器','install_agent':'安装 Agent','upgrade_agent':'升级 Agent',
|
||||
'generate_agent_key':'生成 Agent Key','reveal_install_cmd':'查看安装命令',
|
||||
'batch_install_agent':'批量安装 Agent','batch_upgrade_agent':'批量升级 Agent',
|
||||
'sync_files':'文件推送','sync_preview':'推送预览','sync_commands':'批量命令',
|
||||
'file_delete':'远程删除','file_rename':'远程重命名','file_mkdir':'远程新建目录',
|
||||
'browse_directory':'目录浏览','file_upload':'文件上传',
|
||||
'create_credential':'添加凭据','update_credential':'更新凭据','delete_credential':'删除凭据',
|
||||
'create_script':'添加脚本','update_script':'更新脚本','delete_script':'删除脚本',
|
||||
'execute_started':'执行开始','execute_command':'执行命令','stop_execution':'停止执行',
|
||||
'retry_execution':'重试执行','mark_stuck':'标记卡住','auto_stuck':'自动卡住',
|
||||
'server_auto_stuck':'服务器卡住','script_job_callback':'长任务回调',
|
||||
'login_success':'登录成功','login_locked':'登录锁定','login_failed':'登录失败',
|
||||
'login_ip_blocked':'IP不在白名单',
|
||||
'logout':'登出','change_password':'修改密码',
|
||||
'setup_totp':'设置 TOTP','enable_totp':'启用 TOTP','disable_totp':'禁用 TOTP',
|
||||
'reveal_api_key':'查看 API Key','refresh_reuse_attack':'Token 重用攻击',
|
||||
'refresh_invalid_format':'刷新令牌格式无效','refresh_admin_not_found':'令牌引用管理员不存在',
|
||||
'refresh_token_mismatch':'Token 不匹配','refresh_legacy_not_found':'旧版刷新令牌未找到',
|
||||
'update_setting':'修改设置','create_schedule':'新建调度','update_schedule':'更新调度',
|
||||
'delete_schedule':'删除调度','retry_job':'重试任务','delete_retry':'删除重试',
|
||||
'create_preset':'添加密码预设','update_preset':'更新密码预设','delete_preset':'删除密码预设',
|
||||
'reveal_preset':'查看密码预设',
|
||||
'create_ssh_key_preset':'添加密钥预设','update_ssh_key_preset':'更新密钥预设',
|
||||
'delete_ssh_key_preset':'删除密钥预设','reveal_ssh_key_preset':'查看密钥预设',
|
||||
'webssh_connect':'WebSSH 连接','webssh_disconnect':'WebSSH 断开','webssh_token':'WebSSH 令牌',
|
||||
'refresh_token_mismatch':'Token 不匹配',
|
||||
'create_platform':'添加平台','update_platform':'更新平台','delete_platform':'删除平台',
|
||||
'create_node':'添加节点','update_node':'更新节点','delete_node':'删除节点',
|
||||
'parse_subscription':'解析订阅','update_ip_allowlist':'更新 IP 白名单','toggle_ip_allowlist':'切换 IP 白名单',
|
||||
};
|
||||
const TARGET_NAMES={'server':'服务器','credential':'凭据','db_credential':'数据库凭据','script':'脚本','script_execution':'脚本执行','setting':'设置','schedule':'调度','preset':'密码预设','ssh_key_preset':'密钥预设','platform':'平台','node':'节点','admin':'管理员','retry':'重试'};
|
||||
|
||||
function clearFilters(){
|
||||
document.getElementById('dateFrom').value='';
|
||||
document.getElementById('dateTo').value='';
|
||||
document.getElementById('adminFilter').value='';
|
||||
_offset=0;loadAudit();
|
||||
}
|
||||
function clearFilters(){
|
||||
document.getElementById('dateFrom').value='';
|
||||
document.getElementById('dateTo').value='';
|
||||
document.getElementById('adminFilter').value='';
|
||||
_offset=0;loadAudit();
|
||||
}
|
||||
|
||||
async function loadAudit(){
|
||||
const action=document.getElementById('actionFilter').value;
|
||||
const adminUser=document.getElementById('adminFilter').value.trim();
|
||||
const dateFrom=document.getElementById('dateFrom').value;
|
||||
const dateTo=document.getElementById('dateTo').value;
|
||||
let url=API+'/audit/?limit='+PAGE_SIZE+'&offset='+_offset;
|
||||
if(action)url+='&action='+encodeURIComponent(action);
|
||||
if(adminUser)url+='&admin_username='+encodeURIComponent(adminUser);
|
||||
if(dateFrom)url+='&date_from='+encodeURIComponent(dateFrom);
|
||||
if(dateTo)url+='&date_to='+encodeURIComponent(dateTo+'T23:59:59');
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const data=await r.json();
|
||||
_total=data.total||0;
|
||||
const logs=data.items||[];
|
||||
const tbody=document.getElementById('auditTbody');
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">暂无日志</td></tr>'}else{
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const badge=l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':l.action.includes('create')||l.action.includes('install')?'bg-blue-400/10 text-blue-400':'bg-gray-100 dark:bg-gray-800 text-slate-300';
|
||||
const actionName=ACTION_NAMES[l.action]||l.action;
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-3 text-white">${esc(l.admin_username||'--')}</td>
|
||||
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs ${badge}">${esc(actionName)}</span></td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">${esc(TARGET_NAMES[l.target_type]||l.target_type||'--')} ${l.target_id?'#'+l.target_id:''}</td>
|
||||
<td class="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-xs truncate" title="${esc(l.detail||'')}">${esc((l.detail||'').substring(0,80))}</td>
|
||||
<td class="px-4 py-3 text-gray-300 dark:text-gray-600 text-xs">${esc(l.ip_address||'--')}</td>
|
||||
<td class="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`}).join('');
|
||||
}
|
||||
updatePagination();
|
||||
}
|
||||
async function loadAudit(){
|
||||
const action=document.getElementById('actionFilter').value;
|
||||
const adminUser=document.getElementById('adminFilter').value.trim();
|
||||
const dateFrom=document.getElementById('dateFrom').value;
|
||||
const dateTo=document.getElementById('dateTo').value;
|
||||
let url=API+'/audit/?limit='+PAGE_SIZE+'&offset='+_offset;
|
||||
if(action)url+='&action='+encodeURIComponent(action);
|
||||
if(adminUser)url+='&admin_username='+encodeURIComponent(adminUser);
|
||||
if(dateFrom)url+='&date_from='+encodeURIComponent(dateFrom);
|
||||
if(dateTo)url+='&date_to='+encodeURIComponent(dateTo+'T23:59:59');
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const data=await r.json();
|
||||
_total=data.total||0;
|
||||
const logs=data.items||[];
|
||||
const tbody=document.getElementById('auditTbody');
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无日志</td></tr>'}else{
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const badge=l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':l.action.includes('create')||l.action.includes('install')?'bg-blue-400/10 text-blue-400':'bg-[var(--bg-surface)] text-slate-300';
|
||||
const actionName=ACTION_NAMES[l.action]||l.action;
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-3 text-white">${esc(l.admin_username||'--')}</td>
|
||||
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs ${badge}">${esc(actionName)}</span></td>
|
||||
<td class="px-4 py-3 text-[var(--text-secondary)] text-xs">${esc(TARGET_NAMES[l.target_type]||l.target_type||'--')} ${l.target_id?'#'+l.target_id:''}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-muted)] text-xs max-w-xs truncate" title="${esc(l.detail||'')}">${esc((l.detail||'').substring(0,80))}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-dim)] text-xs">${esc(l.ip_address||'--')}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`}).join('');
|
||||
}
|
||||
updatePagination();
|
||||
}
|
||||
|
||||
function updatePagination(){
|
||||
const page=Math.floor(_offset/PAGE_SIZE)+1;
|
||||
const totalPages=Math.max(1,Math.ceil(_total/PAGE_SIZE));
|
||||
document.getElementById('pagination').classList.toggle('hidden',_total<=PAGE_SIZE);
|
||||
document.getElementById('pageInfo').textContent=`第 ${page}/${totalPages} 页 · 共 ${_total} 条`;
|
||||
document.getElementById('prevBtn').disabled=_offset<=0;
|
||||
document.getElementById('nextBtn').disabled=_offset+PAGE_SIZE>=_total;
|
||||
}
|
||||
function updatePagination(){
|
||||
const page=Math.floor(_offset/PAGE_SIZE)+1;
|
||||
const totalPages=Math.max(1,Math.ceil(_total/PAGE_SIZE));
|
||||
document.getElementById('pagination').classList.toggle('hidden',_total<=PAGE_SIZE);
|
||||
document.getElementById('pageInfo').textContent=`第 ${page}/${totalPages} 页 · 共 ${_total} 条`;
|
||||
document.getElementById('prevBtn').disabled=_offset<=0;
|
||||
document.getElementById('nextBtn').disabled=_offset+PAGE_SIZE>=_total;
|
||||
}
|
||||
|
||||
function prevPage(){_offset=Math.max(0,_offset-PAGE_SIZE);loadAudit()}
|
||||
function nextPage(){if(_offset+PAGE_SIZE<_total){_offset+=PAGE_SIZE;loadAudit()}}
|
||||
function prevPage(){_offset=Math.max(0,_offset-PAGE_SIZE);loadAudit()}
|
||||
function nextPage(){if(_offset+PAGE_SIZE<_total){_offset+=PAGE_SIZE;loadAudit()}}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmt(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
|
||||
loadAudit();
|
||||
</script>
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmt(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
|
||||
loadAudit();
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+270
-270
@@ -2,296 +2,296 @@
|
||||
<script src="/app/vendor/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- View selector -->
|
||||
<select id="logView" onchange="switchView()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<option value="commands">SSH 命令记录</option>
|
||||
<option value="sessions">SSH 会话</option>
|
||||
<option value="push">推送日志</option>
|
||||
</select>
|
||||
<!-- Server filter (all views) -->
|
||||
<select id="serverFilter" onchange="onFilterChange()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm max-w-[160px]">
|
||||
<option value="">全部服务器</option>
|
||||
</select>
|
||||
<!-- Push-specific filters (only shown for push view) -->
|
||||
<select id="pushStatusFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="running">运行中</option>
|
||||
</select>
|
||||
<select id="pushModeFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部模式</option>
|
||||
<option value="incremental">增量</option>
|
||||
<option value="full">全量</option>
|
||||
<option value="overwrite">覆盖</option>
|
||||
<option value="checksum">校验和</option>
|
||||
<option value="command">命令</option>
|
||||
</select>
|
||||
<button onclick="loadData()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- View selector -->
|
||||
<select id="logView" onchange="switchView()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="commands">SSH 命令记录</option>
|
||||
<option value="sessions">SSH 会话</option>
|
||||
<option value="push">推送日志</option>
|
||||
</select>
|
||||
<!-- Server filter (all views) -->
|
||||
<select id="serverFilter" onchange="onFilterChange()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm max-w-[160px]">
|
||||
<option value="">全部服务器</option>
|
||||
</select>
|
||||
<!-- Push-specific filters (only shown for push view) -->
|
||||
<select id="pushStatusFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="running">运行中</option>
|
||||
</select>
|
||||
<select id="pushModeFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部模式</option>
|
||||
<option value="incremental">增量</option>
|
||||
<option value="full">全量</option>
|
||||
<option value="overwrite">覆盖</option>
|
||||
<option value="checksum">校验和</option>
|
||||
<option value="command">命令</option>
|
||||
</select>
|
||||
<button onclick="loadData()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
|
||||
<!-- Commands View -->
|
||||
<div id="commandsView">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[640px]">
|
||||
<thead class="bg-slate-800/50 text-gray-500 dark:text-gray-400 text-xs uppercase">
|
||||
<tr><th class="text-left px-4 py-3">命令</th><th class="text-left px-4 py-3 whitespace-nowrap">服务器</th><th class="text-left px-4 py-3">会话</th><th class="text-left px-4 py-3">来源IP</th><th class="text-right px-4 py-3 whitespace-nowrap">时间</th></tr>
|
||||
</thead>
|
||||
<tbody id="commandsTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Commands View -->
|
||||
<div id="commandsView">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[640px]">
|
||||
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
|
||||
<tr><th class="text-left px-4 py-3">命令</th><th class="text-left px-4 py-3 whitespace-nowrap">服务器</th><th class="text-left px-4 py-3">会话</th><th class="text-left px-4 py-3">来源IP</th><th class="text-right px-4 py-3 whitespace-nowrap">时间</th></tr>
|
||||
</thead>
|
||||
<tbody id="commandsTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions View -->
|
||||
<div id="sessionsView" class="hidden">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[700px]">
|
||||
<thead class="bg-slate-800/50 text-gray-500 dark:text-gray-400 text-xs uppercase">
|
||||
<tr><th class="text-left px-4 py-3">会话ID</th><th class="text-left px-4 py-3">服务器</th><th class="text-left px-4 py-3">来源IP</th><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3 whitespace-nowrap">开始时间</th><th class="text-left px-4 py-3 whitespace-nowrap">结束时间</th><th class="text-right px-4 py-3">操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="sessionsTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sessions View -->
|
||||
<div id="sessionsView" class="hidden">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[700px]">
|
||||
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
|
||||
<tr><th class="text-left px-4 py-3">会话ID</th><th class="text-left px-4 py-3">服务器</th><th class="text-left px-4 py-3">来源IP</th><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3 whitespace-nowrap">开始时间</th><th class="text-left px-4 py-3 whitespace-nowrap">结束时间</th><th class="text-right px-4 py-3">操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="sessionsTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Push Logs View -->
|
||||
<div id="pushView" class="hidden space-y-3">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[800px]">
|
||||
<thead class="bg-slate-800/50 text-gray-500 dark:text-gray-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">状态</th>
|
||||
<th class="text-left px-4 py-3">服务器</th>
|
||||
<th class="text-left px-4 py-3">模式</th>
|
||||
<th class="text-left px-4 py-3">触发</th>
|
||||
<th class="text-left px-4 py-3">源路径</th>
|
||||
<th class="text-left px-4 py-3">目标路径</th>
|
||||
<th class="text-right px-4 py-3">文件数</th>
|
||||
<th class="text-right px-4 py-3">耗时</th>
|
||||
<th class="text-left px-4 py-3">操作人</th>
|
||||
<th class="text-right px-4 py-3 whitespace-nowrap">开始时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pushTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="10" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div id="pushPagination" class="hidden flex items-center justify-between text-sm">
|
||||
<span id="pushTotal" class="text-gray-400 dark:text-gray-500 text-xs"></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="pushPrevBtn" onclick="pushPage(-1)" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
|
||||
<span id="pushPageInfo" class="text-gray-500 dark:text-gray-400 text-xs"></span>
|
||||
<button id="pushNextBtn" onclick="pushPage(1)" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Push Logs View -->
|
||||
<div id="pushView" class="hidden space-y-3">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[800px]">
|
||||
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">状态</th>
|
||||
<th class="text-left px-4 py-3">服务器</th>
|
||||
<th class="text-left px-4 py-3">模式</th>
|
||||
<th class="text-left px-4 py-3">触发</th>
|
||||
<th class="text-left px-4 py-3">源路径</th>
|
||||
<th class="text-left px-4 py-3">目标路径</th>
|
||||
<th class="text-right px-4 py-3">文件数</th>
|
||||
<th class="text-right px-4 py-3">耗时</th>
|
||||
<th class="text-left px-4 py-3">操作人</th>
|
||||
<th class="text-right px-4 py-3 whitespace-nowrap">开始时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pushTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="10" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div id="pushPagination" class="hidden flex items-center justify-between text-sm">
|
||||
<span id="pushTotal" class="text-[var(--text-muted)] text-xs"></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="pushPrevBtn" onclick="pushPage(-1)" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
|
||||
<span id="pushPageInfo" class="text-[var(--text-secondary)] text-xs"></span>
|
||||
<button id="pushNextBtn" onclick="pushPage(1)" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('commands');
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('commands');
|
||||
|
||||
let _currentView = 'commands';
|
||||
let _servers = {};
|
||||
let _pushOffset = 0;
|
||||
const PUSH_PAGE_SIZE = 50;
|
||||
let _pushTotal = 0;
|
||||
let _currentView = 'commands';
|
||||
let _servers = {};
|
||||
let _pushOffset = 0;
|
||||
const PUSH_PAGE_SIZE = 50;
|
||||
let _pushTotal = 0;
|
||||
|
||||
async function loadServers() {
|
||||
try {
|
||||
const r = await apiFetch(API + '/servers/?per_page=200');
|
||||
if (!r) return;
|
||||
const data = await r.json();
|
||||
const sel = document.getElementById('serverFilter');
|
||||
(data.items || []).forEach(s => {
|
||||
_servers[s.id] = s.name;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = s.name + ' (' + s.domain + ')';
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
async function loadServers() {
|
||||
try {
|
||||
const r = await apiFetch(API + '/servers/?per_page=200');
|
||||
if (!r) return;
|
||||
const data = await r.json();
|
||||
const sel = document.getElementById('serverFilter');
|
||||
(data.items || []).forEach(s => {
|
||||
_servers[s.id] = s.name;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = s.name + ' (' + s.domain + ')';
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function switchView() {
|
||||
_currentView = document.getElementById('logView').value;
|
||||
document.getElementById('commandsView').classList.toggle('hidden', _currentView !== 'commands');
|
||||
document.getElementById('sessionsView').classList.toggle('hidden', _currentView !== 'sessions');
|
||||
document.getElementById('pushView').classList.toggle('hidden', _currentView !== 'push');
|
||||
// Show/hide push-specific filters
|
||||
const isPush = _currentView === 'push';
|
||||
document.getElementById('pushStatusFilter').classList.toggle('hidden', !isPush);
|
||||
document.getElementById('pushModeFilter').classList.toggle('hidden', !isPush);
|
||||
_pushOffset = 0;
|
||||
loadData();
|
||||
}
|
||||
function switchView() {
|
||||
_currentView = document.getElementById('logView').value;
|
||||
document.getElementById('commandsView').classList.toggle('hidden', _currentView !== 'commands');
|
||||
document.getElementById('sessionsView').classList.toggle('hidden', _currentView !== 'sessions');
|
||||
document.getElementById('pushView').classList.toggle('hidden', _currentView !== 'push');
|
||||
// Show/hide push-specific filters
|
||||
const isPush = _currentView === 'push';
|
||||
document.getElementById('pushStatusFilter').classList.toggle('hidden', !isPush);
|
||||
document.getElementById('pushModeFilter').classList.toggle('hidden', !isPush);
|
||||
_pushOffset = 0;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
_pushOffset = 0;
|
||||
loadData();
|
||||
}
|
||||
function onFilterChange() {
|
||||
_pushOffset = 0;
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (_currentView === 'commands') await loadCommands();
|
||||
else if (_currentView === 'sessions') await loadSessions();
|
||||
else await loadPushLogs();
|
||||
}
|
||||
async function loadData() {
|
||||
if (_currentView === 'commands') await loadCommands();
|
||||
else if (_currentView === 'sessions') await loadSessions();
|
||||
else await loadPushLogs();
|
||||
}
|
||||
|
||||
async function loadCommands() {
|
||||
const serverId = document.getElementById('serverFilter').value;
|
||||
let url = API + '/assets/command-logs?limit=200';
|
||||
if (serverId) url += '&server_id=' + serverId;
|
||||
const r = await apiFetch(url);
|
||||
if (!r) return;
|
||||
const logs = await r.json();
|
||||
const tbody = document.getElementById('commandsTbody');
|
||||
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">暂无命令日志</td></tr>'; return; }
|
||||
tbody.innerHTML = logs.map(l => {
|
||||
const cmdShort = (l.command || '').length > 120 ? l.command.substring(0, 120) + '...' : l.command;
|
||||
const isDanger = /rm\s|chmod\s|chown\s|dd\s|mkfs|fdisk|:()\s*\{.*&\s*\}|wget\s.*\|\s*(ba)?sh|curl\s.*\|\s*(ba)?sh/i.test(l.command || '');
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5 font-mono text-xs ${isDanger?'text-red-400':'text-slate-200'} max-w-xs truncate" title="${esc(l.command||'')}">${esc(cmdShort)}</td>
|
||||
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5"><a href="#" onclick="filterBySession('${l.session_id||''}');return false" class="text-brand-light hover:underline text-xs font-mono">${esc((l.session_id||'').substring(0,8)||'--')}</a></td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">${esc(l.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
async function loadCommands() {
|
||||
const serverId = document.getElementById('serverFilter').value;
|
||||
let url = API + '/assets/command-logs?limit=200';
|
||||
if (serverId) url += '&server_id=' + serverId;
|
||||
const r = await apiFetch(url);
|
||||
if (!r) return;
|
||||
const logs = await r.json();
|
||||
const tbody = document.getElementById('commandsTbody');
|
||||
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无命令日志</td></tr>'; return; }
|
||||
tbody.innerHTML = logs.map(l => {
|
||||
const cmdShort = (l.command || '').length > 120 ? l.command.substring(0, 120) + '...' : l.command;
|
||||
const isDanger = /rm\s|chmod\s|chown\s|dd\s|mkfs|fdisk|:()\s*\{.*&\s*\}|wget\s.*\|\s*(ba)?sh|curl\s.*\|\s*(ba)?sh/i.test(l.command || '');
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5 font-mono text-xs ${isDanger?'text-red-400':'text-slate-200'} max-w-xs truncate" title="${esc(l.command||'')}">${esc(cmdShort)}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5"><a href="#" onclick="filterBySession('${l.session_id||''}');return false" class="text-brand-light hover:underline text-xs font-mono">${esc((l.session_id||'').substring(0,8)||'--')}</a></td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(l.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
const serverId = document.getElementById('serverFilter').value;
|
||||
let url = API + '/assets/ssh-sessions?limit=50';
|
||||
if (serverId) url += '&server_id=' + serverId;
|
||||
const r = await apiFetch(url);
|
||||
if (!r) return;
|
||||
const sessions = await r.json();
|
||||
const tbody = document.getElementById('sessionsTbody');
|
||||
if (!sessions.length) { tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">暂无SSH会话</td></tr>'; return; }
|
||||
tbody.innerHTML = sessions.map(s => {
|
||||
const badge = s.status==='active'
|
||||
?'<span class="px-2 py-0.5 rounded text-xs bg-green-400/10 text-green-400">在线</span>'
|
||||
:'<span class="px-2 py-0.5 rounded text-xs bg-slate-700 text-gray-500 dark:text-gray-400">已断开</span>';
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-brand-light">${esc((s.id||'').substring(0,8))}...</td>
|
||||
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">${esc(_servers[s.server_id]||'#'+s.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">${esc(s.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5">${badge}</td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs whitespace-nowrap">${fmt(s.started_at)}</td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs whitespace-nowrap">${fmt(s.closed_at)}</td>
|
||||
<td class="px-4 py-2.5 text-right"><button onclick="viewSessionCommands('${s.id}')" class="text-brand-light hover:text-white text-xs transition">查看命令</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
async function loadSessions() {
|
||||
const serverId = document.getElementById('serverFilter').value;
|
||||
let url = API + '/assets/ssh-sessions?limit=50';
|
||||
if (serverId) url += '&server_id=' + serverId;
|
||||
const r = await apiFetch(url);
|
||||
if (!r) return;
|
||||
const sessions = await r.json();
|
||||
const tbody = document.getElementById('sessionsTbody');
|
||||
if (!sessions.length) { tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无SSH会话</td></tr>'; return; }
|
||||
tbody.innerHTML = sessions.map(s => {
|
||||
const badge = s.status==='active'
|
||||
?'<span class="px-2 py-0.5 rounded text-xs bg-green-400/10 text-green-400">在线</span>'
|
||||
:'<span class="px-2 py-0.5 rounded text-xs bg-slate-700 text-[var(--text-secondary)]">已断开</span>';
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-brand-light">${esc((s.id||'').substring(0,8))}...</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs whitespace-nowrap">${esc(_servers[s.server_id]||'#'+s.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(s.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5">${badge}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs whitespace-nowrap">${fmt(s.started_at)}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs whitespace-nowrap">${fmt(s.closed_at)}</td>
|
||||
<td class="px-4 py-2.5 text-right"><button onclick="viewSessionCommands('${s.id}')" class="text-brand-light hover:text-white text-xs transition">查看命令</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Push Logs ──
|
||||
async function loadPushLogs() {
|
||||
const serverId = document.getElementById('serverFilter').value;
|
||||
const status = document.getElementById('pushStatusFilter').value;
|
||||
const mode = document.getElementById('pushModeFilter').value;
|
||||
// ── Push Logs ──
|
||||
async function loadPushLogs() {
|
||||
const serverId = document.getElementById('serverFilter').value;
|
||||
const status = document.getElementById('pushStatusFilter').value;
|
||||
const mode = document.getElementById('pushModeFilter').value;
|
||||
|
||||
let url = `${API}/servers/logs?limit=${PUSH_PAGE_SIZE}&offset=${_pushOffset}`;
|
||||
if (serverId) url += '&server_id=' + serverId;
|
||||
if (status) url += '&status=' + status;
|
||||
if (mode) url += '&sync_mode=' + mode;
|
||||
let url = `${API}/servers/logs?limit=${PUSH_PAGE_SIZE}&offset=${_pushOffset}`;
|
||||
if (serverId) url += '&server_id=' + serverId;
|
||||
if (status) url += '&status=' + status;
|
||||
if (mode) url += '&sync_mode=' + mode;
|
||||
|
||||
const r = await apiFetch(url);
|
||||
if (!r) return;
|
||||
const res = await r.json();
|
||||
const items = res.items || [];
|
||||
_pushTotal = res.total || 0;
|
||||
const r = await apiFetch(url);
|
||||
if (!r) return;
|
||||
const res = await r.json();
|
||||
const items = res.items || [];
|
||||
_pushTotal = res.total || 0;
|
||||
|
||||
const tbody = document.getElementById('pushTbody');
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">暂无推送记录</td></tr>';
|
||||
document.getElementById('pushPagination').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const tbody = document.getElementById('pushTbody');
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无推送记录</td></tr>';
|
||||
document.getElementById('pushPagination').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = items.map(l => {
|
||||
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':l.status==='running'?'text-yellow-400':'text-gray-500 dark:text-gray-400';
|
||||
const scDot = l.status==='success'?'bg-green-400':l.status==='failed'?'bg-red-400':l.status==='running'?'bg-yellow-400 animate-pulse':'bg-slate-500';
|
||||
const modeBadge = {incremental:'增量',full:'全量',overwrite:'覆盖',checksum:'校验和',command:'命令'}[l.sync_mode]||l.sync_mode||'--';
|
||||
const triggerBadge = {manual:'手动',schedule:'定时',retry:'重试',batch:'批量'}[l.trigger_type]||l.trigger_type||'--';
|
||||
const errTitle = l.error_message ? ` title="${esc(l.error_message)}"` : '';
|
||||
return `<tr class="hover:bg-slate-800/30 transition"${errTitle}>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs ${sc}">
|
||||
<span class="w-1.5 h-1.5 rounded-full ${scDot} inline-block shrink-0"></span>
|
||||
${esc(l.status||'--')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-slate-300 text-xs whitespace-nowrap">${esc(l.server_name||_servers[l.server_id]||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">${esc(modeBadge)}</span></td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">${esc(triggerBadge)}</td>
|
||||
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs font-mono max-w-[140px] truncate" title="${esc(l.source_path||'')}">${esc(l.source_path||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs font-mono max-w-[140px] truncate" title="${esc(l.target_path||'')}">${esc(l.target_path||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs text-right">${l.files_transferred!=null?l.files_transferred:'--'}</td>
|
||||
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs text-right whitespace-nowrap">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">${esc(l.operator||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs text-right whitespace-nowrap">${fmt(l.started_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
tbody.innerHTML = items.map(l => {
|
||||
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':l.status==='running'?'text-yellow-400':'text-[var(--text-secondary)]';
|
||||
const scDot = l.status==='success'?'bg-green-400':l.status==='failed'?'bg-red-400':l.status==='running'?'bg-yellow-400 animate-pulse':'bg-slate-500';
|
||||
const modeBadge = {incremental:'增量',full:'全量',overwrite:'覆盖',checksum:'校验和',command:'命令'}[l.sync_mode]||l.sync_mode||'--';
|
||||
const triggerBadge = {manual:'手动',schedule:'定时',retry:'重试',batch:'批量'}[l.trigger_type]||l.trigger_type||'--';
|
||||
const errTitle = l.error_message ? ` title="${esc(l.error_message)}"` : '';
|
||||
return `<tr class="hover:bg-slate-800/30 transition"${errTitle}>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs ${sc}">
|
||||
<span class="w-1.5 h-1.5 rounded-full ${scDot} inline-block shrink-0"></span>
|
||||
${esc(l.status||'--')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-slate-300 text-xs whitespace-nowrap">${esc(l.server_name||_servers[l.server_id]||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-secondary)]">${esc(modeBadge)}</span></td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(triggerBadge)}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs font-mono max-w-[140px] truncate" title="${esc(l.source_path||'')}">${esc(l.source_path||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs font-mono max-w-[140px] truncate" title="${esc(l.target_path||'')}">${esc(l.target_path||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs text-right">${l.files_transferred!=null?l.files_transferred:'--'}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs text-right whitespace-nowrap">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(l.operator||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.started_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Pagination controls
|
||||
const pg = document.getElementById('pushPagination');
|
||||
const curPage = Math.floor(_pushOffset / PUSH_PAGE_SIZE) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil(_pushTotal / PUSH_PAGE_SIZE));
|
||||
pg.classList.remove('hidden');
|
||||
document.getElementById('pushTotal').textContent = `共 ${_pushTotal} 条记录`;
|
||||
document.getElementById('pushPageInfo').textContent = `第 ${curPage} / ${totalPages} 页`;
|
||||
document.getElementById('pushPrevBtn').disabled = _pushOffset === 0;
|
||||
document.getElementById('pushNextBtn').disabled = _pushOffset + PUSH_PAGE_SIZE >= _pushTotal;
|
||||
}
|
||||
// Pagination controls
|
||||
const pg = document.getElementById('pushPagination');
|
||||
const curPage = Math.floor(_pushOffset / PUSH_PAGE_SIZE) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil(_pushTotal / PUSH_PAGE_SIZE));
|
||||
pg.classList.remove('hidden');
|
||||
document.getElementById('pushTotal').textContent = `共 ${_pushTotal} 条记录`;
|
||||
document.getElementById('pushPageInfo').textContent = `第 ${curPage} / ${totalPages} 页`;
|
||||
document.getElementById('pushPrevBtn').disabled = _pushOffset === 0;
|
||||
document.getElementById('pushNextBtn').disabled = _pushOffset + PUSH_PAGE_SIZE >= _pushTotal;
|
||||
}
|
||||
|
||||
function pushPage(dir) {
|
||||
_pushOffset = Math.max(0, _pushOffset + dir * PUSH_PAGE_SIZE);
|
||||
loadPushLogs();
|
||||
}
|
||||
function pushPage(dir) {
|
||||
_pushOffset = Math.max(0, _pushOffset + dir * PUSH_PAGE_SIZE);
|
||||
loadPushLogs();
|
||||
}
|
||||
|
||||
// ── Session helpers ──
|
||||
async function viewSessionCommands(sessionId) {
|
||||
document.getElementById('logView').value = 'commands';
|
||||
_currentView = 'commands';
|
||||
switchView();
|
||||
const r = await apiFetch(API + '/assets/command-logs?session_id=' + encodeURIComponent(sessionId) + '&limit=500');
|
||||
if (!r) return;
|
||||
const logs = await r.json();
|
||||
const tbody = document.getElementById('commandsTbody');
|
||||
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">该会话无命令记录</td></tr>'; return; }
|
||||
tbody.innerHTML = logs.map(l => {
|
||||
const cmdShort = (l.command || '').length > 120 ? l.command.substring(0, 120) + '...' : l.command;
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-slate-200 max-w-xs truncate" title="${esc(l.command||'')}">${esc(cmdShort)}</td>
|
||||
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-brand-light text-xs font-mono">${esc((l.session_id||'').substring(0,8)||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">${esc(l.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
// ── Session helpers ──
|
||||
async function viewSessionCommands(sessionId) {
|
||||
document.getElementById('logView').value = 'commands';
|
||||
_currentView = 'commands';
|
||||
switchView();
|
||||
const r = await apiFetch(API + '/assets/command-logs?session_id=' + encodeURIComponent(sessionId) + '&limit=500');
|
||||
if (!r) return;
|
||||
const logs = await r.json();
|
||||
const tbody = document.getElementById('commandsTbody');
|
||||
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">该会话无命令记录</td></tr>'; return; }
|
||||
tbody.innerHTML = logs.map(l => {
|
||||
const cmdShort = (l.command || '').length > 120 ? l.command.substring(0, 120) + '...' : l.command;
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-slate-200 max-w-xs truncate" title="${esc(l.command||'')}">${esc(cmdShort)}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-brand-light text-xs font-mono">${esc((l.session_id||'').substring(0,8)||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(l.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function filterBySession(sid) { if (sid) viewSessionCommands(sid); }
|
||||
function filterBySession(sid) { if (sid) viewSessionCommands(sid); }
|
||||
|
||||
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
function fmt(t) { if (!t) return '--'; return new Date(t + 'Z').toLocaleString('zh-CN', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit' }); }
|
||||
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
function fmt(t) { if (!t) return '--'; return new Date(t + 'Z').toLocaleString('zh-CN', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit' }); }
|
||||
|
||||
loadServers().then(() => loadData());
|
||||
</script>
|
||||
loadServers().then(() => loadData());
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+370
-370
@@ -2,402 +2,402 @@
|
||||
<script src="/app/vendor/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 min-h-screen flex" x-data="{sidebarOpen:true,tab:'db'}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
|
||||
<button onclick="showAddCred()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
|
||||
</header>
|
||||
<!-- Tabs -->
|
||||
<div class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 flex">
|
||||
<button onclick="switchTab('db')" id="tabDB" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">数据库凭据</button>
|
||||
<button onclick="switchTab('preset')" id="tabPreset" class="px-4 py-3 text-sm border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-white transition">密码预设</button>
|
||||
<button onclick="switchTab('sshkey')" id="tabSSHKey" class="px-4 py-3 text-sm border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-white transition">SSH密钥预设</button>
|
||||
</div>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<!-- DB Credentials -->
|
||||
<div id="dbSection">
|
||||
<div id="credsList" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
<!-- Password Presets -->
|
||||
<div id="presetSection" class="hidden">
|
||||
<div id="presetsList" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
<!-- SSH Key Presets -->
|
||||
<div id="sshKeySection" class="hidden">
|
||||
<div id="sshKeyPresetsList" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit DB Credential Modal -->
|
||||
<div id="credModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-xl p-6 w-full max-w-md space-y-3">
|
||||
<h2 id="credModalTitle" class="text-white font-semibold text-lg">新建数据库凭据</h2>
|
||||
<input type="hidden" id="credEditId" value="">
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">名称</label><input id="credName" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="prod-mysql"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">类型</label><select id="credType" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm"><option value="mysql">MySQL</option><option value="postgresql">PostgreSQL</option><option value="mariadb">MariaDB</option><option value="mongodb">MongoDB</option><option value="redis">Redis</option></select></div>
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">端口</label><input id="credPort" type="number" value="3306" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm"></div>
|
||||
</div>
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">主机</label><input id="credHost" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">用户名</label><input id="credUser" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="root"></div>
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">密码</label><input id="credPass" type="password" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
|
||||
</div>
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">数据库</label><input id="credDb" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="可选"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveCred()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideCredModal()" class="px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Password Preset Modal -->
|
||||
<div id="presetModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-xl p-6 w-full max-w-md space-y-3">
|
||||
<h2 id="presetModalTitle" class="text-white font-semibold text-lg">新建密码预设</h2>
|
||||
<input type="hidden" id="presetEditId" value="">
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">名称</label><input id="presetName" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="生产环境root密码"></div>
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">密码</label><input id="presetPass" type="password" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
|
||||
<div class="bg-gray-100/50 dark:bg-gray-800/50 rounded-lg p-3 text-xs text-gray-400 dark:text-gray-500">密码将使用 AES-256 加密后存储,不可逆向查看。推送时自动解密使用。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="savePreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePresetModal()" class="px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit SSH Key Preset Modal -->
|
||||
<div id="sshKeyModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 id="sshKeyModalTitle" class="text-white font-semibold text-lg">新建SSH密钥预设</h2>
|
||||
<input type="hidden" id="sshKeyEditId" value="">
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">名称</label><input id="sshKeyName" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="生产环境RSA密钥"></div>
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">私钥内容</label><textarea id="sshKeyPrivate" rows="6" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(编辑时留空则不修改)"></textarea></div>
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">公钥内容 <span class="text-gray-300 dark:text-gray-600">(可选)</span></label><textarea id="sshKeyPublic" rows="2" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm font-mono" placeholder="ssh-rsa AAAA... (可选)"></textarea></div>
|
||||
<div class="bg-gray-100/50 dark:bg-gray-800/50 rounded-lg p-3 text-xs text-gray-400 dark:text-gray-500">私钥将使用 Fernet 加密后存储,不可逆向查看。添加服务器选择此预设时自动解密使用。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveSSHKeyPreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideSSHKeyModal()" class="px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reveal SSH Key Modal (re-auth required) -->
|
||||
<div id="sshKeyRevealModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 class="text-white font-semibold text-lg">查看密钥 — 身份验证</h2>
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">当前登录密码</label><input id="sshKeyRevealPwd" type="password" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="输入当前密码以解密查看私钥"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="doRevealSSHKey()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">验证并查看</button><button onclick="hideSSHKeyRevealModal()" class="px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revealed SSH Key Content Modal -->
|
||||
<div id="sshKeyContentModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 id="sshKeyContentTitle" class="text-white font-semibold text-lg">密钥内容</h2>
|
||||
<div>
|
||||
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">私钥</label>
|
||||
<textarea id="sshKeyRevealedPrivate" rows="8" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm font-mono" readonly></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">公钥</label>
|
||||
<textarea id="sshKeyRevealedPublic" rows="2" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm font-mono" readonly></textarea>
|
||||
</div>
|
||||
<div class="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-xs text-amber-400">⚠️ 私钥仅在本次查看中显示,关闭后需重新验证身份才能再次查看。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="hideSSHKeyContentModal()" class="px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true,tab:'db'}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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>
|
||||
<button onclick="showAddCred()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
|
||||
</header>
|
||||
<!-- Tabs -->
|
||||
<div class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 flex">
|
||||
<button onclick="switchTab('db')" id="tabDB" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">数据库凭据</button>
|
||||
<button onclick="switchTab('preset')" id="tabPreset" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">密码预设</button>
|
||||
<button onclick="switchTab('sshkey')" id="tabSSHKey" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">SSH密钥预设</button>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('credentials');
|
||||
const DB_ICONS={mysql:'🐬',postgresql:'🐘',mariadb:'🐬',mongodb:'🍃',redis:'🔴'};
|
||||
const DB_PORTS={mysql:3306,postgresql:5432,mariadb:3306,mongodb:27017,redis:6379};
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<!-- DB Credentials -->
|
||||
<div id="dbSection">
|
||||
<div id="credsList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
<!-- Password Presets -->
|
||||
<div id="presetSection" class="hidden">
|
||||
<div id="presetsList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
<!-- SSH Key Presets -->
|
||||
<div id="sshKeySection" class="hidden">
|
||||
<div id="sshKeyPresetsList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
function switchTab(t){
|
||||
document.getElementById('tabDB').className='px-4 py-3 text-sm border-b-2 '+(t==='db'?'border-brand text-brand-light':'border-transparent text-gray-500 dark:text-gray-400 hover:text-white')+' transition';
|
||||
document.getElementById('tabPreset').className='px-4 py-3 text-sm border-b-2 '+(t==='preset'?'border-brand text-brand-light':'border-transparent text-gray-500 dark:text-gray-400 hover:text-white')+' transition';
|
||||
document.getElementById('tabSSHKey').className='px-4 py-3 text-sm border-b-2 '+(t==='sshkey'?'border-brand text-brand-light':'border-transparent text-gray-500 dark:text-gray-400 hover:text-white')+' transition';
|
||||
document.getElementById('dbSection').classList.toggle('hidden',t!=='db');
|
||||
document.getElementById('presetSection').classList.toggle('hidden',t!=='preset');
|
||||
document.getElementById('sshKeySection').classList.toggle('hidden',t!=='sshkey');
|
||||
if(t==='preset')loadPresets();
|
||||
if(t==='sshkey')loadSSHKeyPresets();
|
||||
}
|
||||
<!-- Add/Edit DB Credential Modal -->
|
||||
<div id="credModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-md space-y-3">
|
||||
<h2 id="credModalTitle" class="text-white font-semibold text-lg">新建数据库凭据</h2>
|
||||
<input type="hidden" id="credEditId" value="">
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称</label><input id="credName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="prod-mysql"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">类型</label><select id="credType" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="mysql">MySQL</option><option value="postgresql">PostgreSQL</option><option value="mariadb">MariaDB</option><option value="mongodb">MongoDB</option><option value="redis">Redis</option></select></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">端口</label><input id="credPort" type="number" value="3306" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"></div>
|
||||
</div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">主机</label><input id="credHost" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">用户名</label><input id="credUser" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="root"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">密码</label><input id="credPass" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
|
||||
</div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">数据库</label><input id="credDb" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="可选"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveCred()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideCredModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ── DB Credentials ──
|
||||
async function loadCreds(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
|
||||
const creds=await r.json();
|
||||
document.getElementById('credsList').innerHTML=creds.length?creds.map(c=>`<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">${DB_ICONS[c.db_type]||'🗄'}</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(c.name)}</span>
|
||||
<div class="text-gray-400 dark:text-gray-500 text-xs mt-0.5">${esc(c.db_type)} · ${esc(c.host)}:${c.port} · ${esc(c.username)}${c.database?' / '+esc(c.database):''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editCred(${c.id})" class="text-gray-400 dark:text-gray-500 hover:text-white text-xs">编辑</button>
|
||||
<button onclick="deleteCred(${c.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-gray-400 dark:text-gray-500 text-center py-8">暂无数据库凭据</div>';
|
||||
}catch(e){document.getElementById('credsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
<!-- Add/Edit Password Preset Modal -->
|
||||
<div id="presetModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-md space-y-3">
|
||||
<h2 id="presetModalTitle" class="text-white font-semibold text-lg">新建密码预设</h2>
|
||||
<input type="hidden" id="presetEditId" value="">
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称</label><input id="presetName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="生产环境root密码"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">密码</label><input id="presetPass" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
|
||||
<div class="bg-gray-100/50 rounded-lg p-3 text-xs text-[var(--text-muted)]">密码将使用 AES-256 加密后存储,不可逆向查看。推送时自动解密使用。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="savePreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePresetModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
function showAddCred(){
|
||||
if(!document.getElementById('dbSection').classList.contains('hidden')){
|
||||
showAddCredForm();
|
||||
}else if(!document.getElementById('presetSection').classList.contains('hidden')){
|
||||
showAddPresetForm();
|
||||
}else{
|
||||
showAddSSHKeyForm();
|
||||
}
|
||||
}
|
||||
<!-- Add/Edit SSH Key Preset Modal -->
|
||||
<div id="sshKeyModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 id="sshKeyModalTitle" class="text-white font-semibold text-lg">新建SSH密钥预设</h2>
|
||||
<input type="hidden" id="sshKeyEditId" value="">
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称</label><input id="sshKeyName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="生产环境RSA密钥"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">私钥内容</label><textarea id="sshKeyPrivate" rows="6" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(编辑时留空则不修改)"></textarea></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">公钥内容 <span class="text-[var(--text-dim)]">(可选)</span></label><textarea id="sshKeyPublic" rows="2" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="ssh-rsa AAAA... (可选)"></textarea></div>
|
||||
<div class="bg-gray-100/50 rounded-lg p-3 text-xs text-[var(--text-muted)]">私钥将使用 Fernet 加密后存储,不可逆向查看。添加服务器选择此预设时自动解密使用。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveSSHKeyPreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideSSHKeyModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
function showAddCredForm(){
|
||||
document.getElementById('credModalTitle').textContent='新建数据库凭据';
|
||||
document.getElementById('credEditId').value='';
|
||||
['credName','credHost','credUser','credPass','credDb'].forEach(id=>document.getElementById(id).value='');
|
||||
document.getElementById('credType').value='mysql';
|
||||
document.getElementById('credPort').value='3306';
|
||||
document.getElementById('credPass').placeholder='输入密码';
|
||||
document.getElementById('credModal').classList.remove('hidden');
|
||||
}
|
||||
<!-- Reveal SSH Key Modal (re-auth required) -->
|
||||
<div id="sshKeyRevealModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 class="text-white font-semibold text-lg">查看密钥 — 身份验证</h2>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">当前登录密码</label><input id="sshKeyRevealPwd" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="输入当前密码以解密查看私钥"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="doRevealSSHKey()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">验证并查看</button><button onclick="hideSSHKeyRevealModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
async function editCred(id){
|
||||
try{
|
||||
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
|
||||
const creds=await r.json();
|
||||
const c=creds.find(x=>x.id===id);
|
||||
if(!c){toast('error','凭据未找到');return}
|
||||
document.getElementById('credModalTitle').textContent='编辑数据库凭据';
|
||||
document.getElementById('credEditId').value=id;
|
||||
document.getElementById('credName').value=c.name||'';
|
||||
document.getElementById('credType').value=c.db_type||'mysql';
|
||||
document.getElementById('credPort').value=c.port||3306;
|
||||
document.getElementById('credHost').value=c.host||'';
|
||||
document.getElementById('credUser').value=c.username||'';
|
||||
document.getElementById('credPass').value='';
|
||||
document.getElementById('credPass').placeholder='留空则不修改';
|
||||
document.getElementById('credDb').value=c.database||'';
|
||||
document.getElementById('credModal').classList.remove('hidden');
|
||||
}catch(e){toast('error','加载失败')}
|
||||
}
|
||||
<!-- Revealed SSH Key Content Modal -->
|
||||
<div id="sshKeyContentModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 id="sshKeyContentTitle" class="text-white font-semibold text-lg">密钥内容</h2>
|
||||
<div>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">私钥</label>
|
||||
<textarea id="sshKeyRevealedPrivate" rows="8" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" readonly></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">公钥</label>
|
||||
<textarea id="sshKeyRevealedPublic" rows="2" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" readonly></textarea>
|
||||
</div>
|
||||
<div class="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-xs text-amber-400">⚠️ 私钥仅在本次查看中显示,关闭后需重新验证身份才能再次查看。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="hideSSHKeyContentModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('credentials');
|
||||
const DB_ICONS={mysql:'🐬',postgresql:'🐘',mariadb:'🐬',mongodb:'🍃',redis:'🔴'};
|
||||
const DB_PORTS={mysql:3306,postgresql:5432,mariadb:3306,mongodb:27017,redis:6379};
|
||||
|
||||
function showAddPresetForm(){
|
||||
document.getElementById('presetModalTitle').textContent='新建密码预设';
|
||||
document.getElementById('presetEditId').value='';
|
||||
document.getElementById('presetName').value='';
|
||||
document.getElementById('presetPass').value='';
|
||||
document.getElementById('presetPass').placeholder='输入密码';
|
||||
document.getElementById('presetModal').classList.remove('hidden');
|
||||
}
|
||||
function switchTab(t){
|
||||
document.getElementById('tabDB').className='px-4 py-3 text-sm border-b-2 '+(t==='db'?'border-brand text-brand-light':'border-transparent text-[var(--text-secondary)] hover:text-white')+' transition';
|
||||
document.getElementById('tabPreset').className='px-4 py-3 text-sm border-b-2 '+(t==='preset'?'border-brand text-brand-light':'border-transparent text-[var(--text-secondary)] hover:text-white')+' transition';
|
||||
document.getElementById('tabSSHKey').className='px-4 py-3 text-sm border-b-2 '+(t==='sshkey'?'border-brand text-brand-light':'border-transparent text-[var(--text-secondary)] hover:text-white')+' transition';
|
||||
document.getElementById('dbSection').classList.toggle('hidden',t!=='db');
|
||||
document.getElementById('presetSection').classList.toggle('hidden',t!=='preset');
|
||||
document.getElementById('sshKeySection').classList.toggle('hidden',t!=='sshkey');
|
||||
if(t==='preset')loadPresets();
|
||||
if(t==='sshkey')loadSSHKeyPresets();
|
||||
}
|
||||
|
||||
function editPreset(id,name){
|
||||
document.getElementById('presetModalTitle').textContent='编辑密码预设';
|
||||
document.getElementById('presetEditId').value=id;
|
||||
document.getElementById('presetName').value=name;
|
||||
document.getElementById('presetPass').value='';
|
||||
document.getElementById('presetPass').placeholder='留空则不修改';
|
||||
document.getElementById('presetModal').classList.remove('hidden');
|
||||
}
|
||||
// ── DB Credentials ──
|
||||
async function loadCreds(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
|
||||
const creds=await r.json();
|
||||
document.getElementById('credsList').innerHTML=creds.length?creds.map(c=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">${DB_ICONS[c.db_type]||'🗄'}</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(c.name)}</span>
|
||||
<div class="text-[var(--text-muted)] text-xs mt-0.5">${esc(c.db_type)} · ${esc(c.host)}:${c.port} · ${esc(c.username)}${c.database?' / '+esc(c.database):''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editCred(${c.id})" class="text-[var(--text-muted)] hover:text-white text-xs">编辑</button>
|
||||
<button onclick="deleteCred(${c.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无数据库凭据</div>';
|
||||
}catch(e){document.getElementById('credsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
function hideCredModal(){document.getElementById('credModal').classList.add('hidden')}
|
||||
function hidePresetModal(){document.getElementById('presetModal').classList.add('hidden')}
|
||||
function showAddCred(){
|
||||
if(!document.getElementById('dbSection').classList.contains('hidden')){
|
||||
showAddCredForm();
|
||||
}else if(!document.getElementById('presetSection').classList.contains('hidden')){
|
||||
showAddPresetForm();
|
||||
}else{
|
||||
showAddSSHKeyForm();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('credType').addEventListener('change',()=>{
|
||||
const t=document.getElementById('credType').value;
|
||||
document.getElementById('credPort').value=DB_PORTS[t]||3306;
|
||||
});
|
||||
function showAddCredForm(){
|
||||
document.getElementById('credModalTitle').textContent='新建数据库凭据';
|
||||
document.getElementById('credEditId').value='';
|
||||
['credName','credHost','credUser','credPass','credDb'].forEach(id=>document.getElementById(id).value='');
|
||||
document.getElementById('credType').value='mysql';
|
||||
document.getElementById('credPort').value='3306';
|
||||
document.getElementById('credPass').placeholder='输入密码';
|
||||
document.getElementById('credModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function saveCred(){
|
||||
const editId=document.getElementById('credEditId').value;
|
||||
const body={
|
||||
name:document.getElementById('credName').value,
|
||||
db_type:document.getElementById('credType').value,
|
||||
host:document.getElementById('credHost').value,
|
||||
port:parseInt(document.getElementById('credPort').value)||3306,
|
||||
username:document.getElementById('credUser').value,
|
||||
password:document.getElementById('credPass').value||null,
|
||||
database:document.getElementById('credDb').value||null,
|
||||
};
|
||||
if(!body.name||!body.host||!body.username){toast('warning','名称、主机、用户名必填');return}
|
||||
if(!editId&&!body.password){toast('warning','密码必填');return}
|
||||
const url=editId?API+'/scripts/credentials/'+editId:API+'/scripts/credentials';
|
||||
const method=editId?'PUT':'POST';
|
||||
const r=await apiFetch(url,{method,headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok)toast('success',editId?'凭据已更新':'凭据已创建');else toast('error','保存失败');
|
||||
hideCredModal();loadCreds();
|
||||
}
|
||||
async function editCred(id){
|
||||
try{
|
||||
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
|
||||
const creds=await r.json();
|
||||
const c=creds.find(x=>x.id===id);
|
||||
if(!c){toast('error','凭据未找到');return}
|
||||
document.getElementById('credModalTitle').textContent='编辑数据库凭据';
|
||||
document.getElementById('credEditId').value=id;
|
||||
document.getElementById('credName').value=c.name||'';
|
||||
document.getElementById('credType').value=c.db_type||'mysql';
|
||||
document.getElementById('credPort').value=c.port||3306;
|
||||
document.getElementById('credHost').value=c.host||'';
|
||||
document.getElementById('credUser').value=c.username||'';
|
||||
document.getElementById('credPass').value='';
|
||||
document.getElementById('credPass').placeholder='留空则不修改';
|
||||
document.getElementById('credDb').value=c.database||'';
|
||||
document.getElementById('credModal').classList.remove('hidden');
|
||||
}catch(e){toast('error','加载失败')}
|
||||
}
|
||||
|
||||
async function deleteCred(id){
|
||||
if(!confirm('确定删除凭据?'))return;
|
||||
const r=await apiFetch(API+'/scripts/credentials/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','凭据已删除');else toast('error','删除失败');
|
||||
loadCreds();
|
||||
}
|
||||
function showAddPresetForm(){
|
||||
document.getElementById('presetModalTitle').textContent='新建密码预设';
|
||||
document.getElementById('presetEditId').value='';
|
||||
document.getElementById('presetName').value='';
|
||||
document.getElementById('presetPass').value='';
|
||||
document.getElementById('presetPass').placeholder='输入密码';
|
||||
document.getElementById('presetModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ── Password Presets ──
|
||||
async function loadPresets(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/presets/');if(!r)return;
|
||||
const presets=await r.json();
|
||||
document.getElementById('presetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">🔑</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(p.name)}</span>
|
||||
<div class="text-gray-400 dark:text-gray-500 text-xs mt-0.5">加密存储 · 创建于 ${fmtTime(p.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editPreset(${p.id},this.dataset.name)" data-name="${esc(p.name)}" class="text-gray-400 dark:text-gray-500 hover:text-white text-xs">编辑</button>
|
||||
<button onclick="deletePreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-gray-400 dark:text-gray-500 text-center py-8">暂无密码预设</div>';
|
||||
}catch(e){document.getElementById('presetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
function editPreset(id,name){
|
||||
document.getElementById('presetModalTitle').textContent='编辑密码预设';
|
||||
document.getElementById('presetEditId').value=id;
|
||||
document.getElementById('presetName').value=name;
|
||||
document.getElementById('presetPass').value='';
|
||||
document.getElementById('presetPass').placeholder='留空则不修改';
|
||||
document.getElementById('presetModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function savePreset(){
|
||||
const editId=document.getElementById('presetEditId').value;
|
||||
const name=document.getElementById('presetName').value;
|
||||
const pw=document.getElementById('presetPass').value;
|
||||
if(!name){toast('warning','名称必填');return}
|
||||
if(!editId&&!pw){toast('warning','密码必填');return}
|
||||
if(editId){
|
||||
const body={name};
|
||||
if(pw)body.encrypted_pw=pw;
|
||||
const r=await apiFetch(API+'/presets/'+editId,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok)toast('success','预设已更新');else toast('error','更新失败');
|
||||
}else{
|
||||
const r=await apiFetch(API+'/presets/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({name,encrypted_pw:pw})});
|
||||
if(r&&r.ok)toast('success','密码预设已创建');else toast('error','创建失败');
|
||||
}
|
||||
hidePresetModal();loadPresets();
|
||||
}
|
||||
function hideCredModal(){document.getElementById('credModal').classList.add('hidden')}
|
||||
function hidePresetModal(){document.getElementById('presetModal').classList.add('hidden')}
|
||||
|
||||
async function deletePreset(id){
|
||||
if(!confirm('确定删除密码预设?'))return;
|
||||
const r=await apiFetch(API+'/presets/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','预设已删除');else toast('error','删除失败');
|
||||
loadPresets();
|
||||
}
|
||||
document.getElementById('credType').addEventListener('change',()=>{
|
||||
const t=document.getElementById('credType').value;
|
||||
document.getElementById('credPort').value=DB_PORTS[t]||3306;
|
||||
});
|
||||
|
||||
// ── SSH Key Presets ──
|
||||
let _sshKeyRevealId=null;
|
||||
async function saveCred(){
|
||||
const editId=document.getElementById('credEditId').value;
|
||||
const body={
|
||||
name:document.getElementById('credName').value,
|
||||
db_type:document.getElementById('credType').value,
|
||||
host:document.getElementById('credHost').value,
|
||||
port:parseInt(document.getElementById('credPort').value)||3306,
|
||||
username:document.getElementById('credUser').value,
|
||||
password:document.getElementById('credPass').value||null,
|
||||
database:document.getElementById('credDb').value||null,
|
||||
};
|
||||
if(!body.name||!body.host||!body.username){toast('warning','名称、主机、用户名必填');return}
|
||||
if(!editId&&!body.password){toast('warning','密码必填');return}
|
||||
const url=editId?API+'/scripts/credentials/'+editId:API+'/scripts/credentials';
|
||||
const method=editId?'PUT':'POST';
|
||||
const r=await apiFetch(url,{method,headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok)toast('success',editId?'凭据已更新':'凭据已创建');else toast('error','保存失败');
|
||||
hideCredModal();loadCreds();
|
||||
}
|
||||
|
||||
async function loadSSHKeyPresets(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/ssh-key-presets/');if(!r)return;
|
||||
const presets=await r.json();
|
||||
document.getElementById('sshKeyPresetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">🔐</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(p.name)}</span>
|
||||
<div class="text-gray-400 dark:text-gray-500 text-xs mt-0.5">${p.public_key?esc(p.public_key.substring(0,40))+'...':'无公钥'} · 创建于 ${fmtTime(p.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="revealSSHKey(${p.id})" class="text-brand-light hover:text-white text-xs" title="查看密钥内容(需验证身份)">查看</button>
|
||||
<button onclick="editSSHKeyPreset(${p.id},this.dataset.name,this.dataset.pubkey)" data-name="${esc(p.name)}" data-pubkey="${esc(p.public_key||'')}" class="text-gray-400 dark:text-gray-500 hover:text-white text-xs">编辑</button>
|
||||
<button onclick="deleteSSHKeyPreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-gray-400 dark:text-gray-500 text-center py-8">暂无SSH密钥预设</div>';
|
||||
}catch(e){document.getElementById('sshKeyPresetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
async function deleteCred(id){
|
||||
if(!confirm('确定删除凭据?'))return;
|
||||
const r=await apiFetch(API+'/scripts/credentials/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','凭据已删除');else toast('error','删除失败');
|
||||
loadCreds();
|
||||
}
|
||||
|
||||
function showAddSSHKeyForm(){
|
||||
document.getElementById('sshKeyModalTitle').textContent='新建SSH密钥预设';
|
||||
document.getElementById('sshKeyEditId').value='';
|
||||
document.getElementById('sshKeyName').value='';
|
||||
document.getElementById('sshKeyPrivate').value='';
|
||||
document.getElementById('sshKeyPrivate').placeholder='粘贴 PEM 格式私钥内容';
|
||||
document.getElementById('sshKeyPublic').value='';
|
||||
document.getElementById('sshKeyModal').classList.remove('hidden');
|
||||
}
|
||||
// ── Password Presets ──
|
||||
async function loadPresets(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/presets/');if(!r)return;
|
||||
const presets=await r.json();
|
||||
document.getElementById('presetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">🔑</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(p.name)}</span>
|
||||
<div class="text-[var(--text-muted)] text-xs mt-0.5">加密存储 · 创建于 ${fmtTime(p.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editPreset(${p.id},this.dataset.name)" data-name="${esc(p.name)}" class="text-[var(--text-muted)] hover:text-white text-xs">编辑</button>
|
||||
<button onclick="deletePreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无密码预设</div>';
|
||||
}catch(e){document.getElementById('presetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
function editSSHKeyPreset(id,name,pubKey){
|
||||
document.getElementById('sshKeyModalTitle').textContent='编辑SSH密钥预设';
|
||||
document.getElementById('sshKeyEditId').value=id;
|
||||
document.getElementById('sshKeyName').value=name||'';
|
||||
document.getElementById('sshKeyPrivate').value='';
|
||||
document.getElementById('sshKeyPrivate').placeholder='留空则不修改私钥';
|
||||
document.getElementById('sshKeyPublic').value=pubKey||'';
|
||||
document.getElementById('sshKeyModal').classList.remove('hidden');
|
||||
}
|
||||
async function savePreset(){
|
||||
const editId=document.getElementById('presetEditId').value;
|
||||
const name=document.getElementById('presetName').value;
|
||||
const pw=document.getElementById('presetPass').value;
|
||||
if(!name){toast('warning','名称必填');return}
|
||||
if(!editId&&!pw){toast('warning','密码必填');return}
|
||||
if(editId){
|
||||
const body={name};
|
||||
if(pw)body.encrypted_pw=pw;
|
||||
const r=await apiFetch(API+'/presets/'+editId,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok)toast('success','预设已更新');else toast('error','更新失败');
|
||||
}else{
|
||||
const r=await apiFetch(API+'/presets/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({name,encrypted_pw:pw})});
|
||||
if(r&&r.ok)toast('success','密码预设已创建');else toast('error','创建失败');
|
||||
}
|
||||
hidePresetModal();loadPresets();
|
||||
}
|
||||
|
||||
function hideSSHKeyModal(){document.getElementById('sshKeyModal').classList.add('hidden')}
|
||||
async function deletePreset(id){
|
||||
if(!confirm('确定删除密码预设?'))return;
|
||||
const r=await apiFetch(API+'/presets/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','预设已删除');else toast('error','删除失败');
|
||||
loadPresets();
|
||||
}
|
||||
|
||||
async function saveSSHKeyPreset(){
|
||||
const editId=document.getElementById('sshKeyEditId').value;
|
||||
const name=document.getElementById('sshKeyName').value;
|
||||
const privateKey=document.getElementById('sshKeyPrivate').value;
|
||||
const publicKey=document.getElementById('sshKeyPublic').value;
|
||||
if(!name){toast('warning','名称必填');return}
|
||||
if(!editId&&!privateKey){toast('warning','私钥必填');return}
|
||||
const body={name,private_key:privateKey||undefined,public_key:publicKey||undefined};
|
||||
if(editId){
|
||||
if(!privateKey)delete body.private_key;
|
||||
const r=await apiFetch(API+'/ssh-key-presets/'+editId,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok)toast('success','密钥预设已更新');else toast('error','更新失败');
|
||||
}else{
|
||||
const r=await apiFetch(API+'/ssh-key-presets/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok)toast('success','SSH密钥预设已创建');else toast('error','创建失败');
|
||||
}
|
||||
hideSSHKeyModal();loadSSHKeyPresets();
|
||||
}
|
||||
// ── SSH Key Presets ──
|
||||
let _sshKeyRevealId=null;
|
||||
|
||||
async function deleteSSHKeyPreset(id){
|
||||
if(!confirm('确定删除SSH密钥预设?'))return;
|
||||
const r=await apiFetch(API+'/ssh-key-presets/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','密钥预设已删除');else toast('error','删除失败');
|
||||
loadSSHKeyPresets();
|
||||
}
|
||||
async function loadSSHKeyPresets(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/ssh-key-presets/');if(!r)return;
|
||||
const presets=await r.json();
|
||||
document.getElementById('sshKeyPresetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">🔐</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(p.name)}</span>
|
||||
<div class="text-[var(--text-muted)] text-xs mt-0.5">${p.public_key?esc(p.public_key.substring(0,40))+'...':'无公钥'} · 创建于 ${fmtTime(p.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="revealSSHKey(${p.id})" class="text-brand-light hover:text-white text-xs" title="查看密钥内容(需验证身份)">查看</button>
|
||||
<button onclick="editSSHKeyPreset(${p.id},this.dataset.name,this.dataset.pubkey)" data-name="${esc(p.name)}" data-pubkey="${esc(p.public_key||'')}" class="text-[var(--text-muted)] hover:text-white text-xs">编辑</button>
|
||||
<button onclick="deleteSSHKeyPreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无SSH密钥预设</div>';
|
||||
}catch(e){document.getElementById('sshKeyPresetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
// ── Reveal SSH Key (re-auth required) ──
|
||||
function revealSSHKey(id){
|
||||
_sshKeyRevealId=id;
|
||||
document.getElementById('sshKeyRevealPwd').value='';
|
||||
document.getElementById('sshKeyRevealModal').classList.remove('hidden');
|
||||
}
|
||||
function showAddSSHKeyForm(){
|
||||
document.getElementById('sshKeyModalTitle').textContent='新建SSH密钥预设';
|
||||
document.getElementById('sshKeyEditId').value='';
|
||||
document.getElementById('sshKeyName').value='';
|
||||
document.getElementById('sshKeyPrivate').value='';
|
||||
document.getElementById('sshKeyPrivate').placeholder='粘贴 PEM 格式私钥内容';
|
||||
document.getElementById('sshKeyPublic').value='';
|
||||
document.getElementById('sshKeyModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideSSHKeyRevealModal(){
|
||||
document.getElementById('sshKeyRevealModal').classList.add('hidden');
|
||||
_sshKeyRevealId=null;
|
||||
}
|
||||
function editSSHKeyPreset(id,name,pubKey){
|
||||
document.getElementById('sshKeyModalTitle').textContent='编辑SSH密钥预设';
|
||||
document.getElementById('sshKeyEditId').value=id;
|
||||
document.getElementById('sshKeyName').value=name||'';
|
||||
document.getElementById('sshKeyPrivate').value='';
|
||||
document.getElementById('sshKeyPrivate').placeholder='留空则不修改私钥';
|
||||
document.getElementById('sshKeyPublic').value=pubKey||'';
|
||||
document.getElementById('sshKeyModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function doRevealSSHKey(){
|
||||
const pwd=document.getElementById('sshKeyRevealPwd').value;
|
||||
if(!pwd){toast('warning','请输入密码');return}
|
||||
const r=await apiFetch(API+'/ssh-key-presets/'+_sshKeyRevealId+'/reveal',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({current_password:pwd})});
|
||||
if(!r){hideSSHKeyRevealModal();return}
|
||||
if(r.status===400){
|
||||
const d=await r.json();
|
||||
toast('error',d.detail||'密码错误');
|
||||
return;
|
||||
}
|
||||
if(!r.ok){toast('error','查看失败');hideSSHKeyRevealModal();return}
|
||||
const data=await r.json();
|
||||
hideSSHKeyRevealModal();
|
||||
// Show revealed content
|
||||
document.getElementById('sshKeyContentTitle').textContent='密钥内容 — '+esc(data.name);
|
||||
document.getElementById('sshKeyRevealedPrivate').value=data.private_key||'';
|
||||
document.getElementById('sshKeyRevealedPublic').value=data.public_key||'';
|
||||
document.getElementById('sshKeyContentModal').classList.remove('hidden');
|
||||
}
|
||||
function hideSSHKeyModal(){document.getElementById('sshKeyModal').classList.add('hidden')}
|
||||
|
||||
function hideSSHKeyContentModal(){
|
||||
document.getElementById('sshKeyContentModal').classList.add('hidden');
|
||||
document.getElementById('sshKeyRevealedPrivate').value='';
|
||||
document.getElementById('sshKeyRevealedPublic').value='';
|
||||
}
|
||||
async function saveSSHKeyPreset(){
|
||||
const editId=document.getElementById('sshKeyEditId').value;
|
||||
const name=document.getElementById('sshKeyName').value;
|
||||
const privateKey=document.getElementById('sshKeyPrivate').value;
|
||||
const publicKey=document.getElementById('sshKeyPublic').value;
|
||||
if(!name){toast('warning','名称必填');return}
|
||||
if(!editId&&!privateKey){toast('warning','私钥必填');return}
|
||||
const body={name,private_key:privateKey||undefined,public_key:publicKey||undefined};
|
||||
if(editId){
|
||||
if(!privateKey)delete body.private_key;
|
||||
const r=await apiFetch(API+'/ssh-key-presets/'+editId,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok)toast('success','密钥预设已更新');else toast('error','更新失败');
|
||||
}else{
|
||||
const r=await apiFetch(API+'/ssh-key-presets/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok)toast('success','SSH密钥预设已创建');else toast('error','创建失败');
|
||||
}
|
||||
hideSSHKeyModal();loadSSHKeyPresets();
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
async function deleteSSHKeyPreset(id){
|
||||
if(!confirm('确定删除SSH密钥预设?'))return;
|
||||
const r=await apiFetch(API+'/ssh-key-presets/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','密钥预设已删除');else toast('error','删除失败');
|
||||
loadSSHKeyPresets();
|
||||
}
|
||||
|
||||
loadCreds();
|
||||
</script>
|
||||
// ── Reveal SSH Key (re-auth required) ──
|
||||
function revealSSHKey(id){
|
||||
_sshKeyRevealId=id;
|
||||
document.getElementById('sshKeyRevealPwd').value='';
|
||||
document.getElementById('sshKeyRevealModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideSSHKeyRevealModal(){
|
||||
document.getElementById('sshKeyRevealModal').classList.add('hidden');
|
||||
_sshKeyRevealId=null;
|
||||
}
|
||||
|
||||
async function doRevealSSHKey(){
|
||||
const pwd=document.getElementById('sshKeyRevealPwd').value;
|
||||
if(!pwd){toast('warning','请输入密码');return}
|
||||
const r=await apiFetch(API+'/ssh-key-presets/'+_sshKeyRevealId+'/reveal',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({current_password:pwd})});
|
||||
if(!r){hideSSHKeyRevealModal();return}
|
||||
if(r.status===400){
|
||||
const d=await r.json();
|
||||
toast('error',d.detail||'密码错误');
|
||||
return;
|
||||
}
|
||||
if(!r.ok){toast('error','查看失败');hideSSHKeyRevealModal();return}
|
||||
const data=await r.json();
|
||||
hideSSHKeyRevealModal();
|
||||
// Show revealed content
|
||||
document.getElementById('sshKeyContentTitle').textContent='密钥内容 — '+esc(data.name);
|
||||
document.getElementById('sshKeyRevealedPrivate').value=data.private_key||'';
|
||||
document.getElementById('sshKeyRevealedPublic').value=data.public_key||'';
|
||||
document.getElementById('sshKeyContentModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideSSHKeyContentModal(){
|
||||
document.getElementById('sshKeyContentModal').classList.add('hidden');
|
||||
document.getElementById('sshKeyRevealedPrivate').value='';
|
||||
document.getElementById('sshKeyRevealedPublic').value='';
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
|
||||
loadCreds();
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+1010
-1010
File diff suppressed because it is too large
Load Diff
+234
-234
@@ -1,255 +1,255 @@
|
||||
<!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 src="/app/vendor/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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>
|
||||
<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 src="/app/vendor/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="text-gray-500 dark:text-gray-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 class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="text-[var(--text-secondary)] 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>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="refreshBtn" onclick="refreshAll()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition flex items-center gap-1.5">刷新</button>
|
||||
<span id="headerUser" class="text-[var(--text-secondary)] text-sm">...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="text-[var(--text-secondary)] text-sm mb-1">服务器总数</div>
|
||||
<div class="text-3xl font-bold text-white" id="statTotal">--</div>
|
||||
</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="text-[var(--text-secondary)] text-sm mb-1">在线</div>
|
||||
<div class="text-3xl font-bold text-green-400" id="statOnline">--</div>
|
||||
</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="text-[var(--text-secondary)] text-sm mb-1">离线</div>
|
||||
<div class="text-3xl font-bold text-red-400" id="statOffline">--</div>
|
||||
</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="text-[var(--text-secondary)] text-sm mb-1">告警中</div>
|
||||
<div class="text-3xl font-bold text-yellow-400" id="statAlerts">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run status + Category Pie -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="lg:col-span-2">
|
||||
<div id="statusPanel" class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<h2 class="text-white font-semibold text-sm mb-2">运行状态</h2>
|
||||
<div id="statusSummary" class="flex items-center gap-2 text-[var(--text-muted)] text-sm">
|
||||
<span id="statusDot" class="inline-block w-2 h-2 rounded-full bg-slate-600 shrink-0"></span>
|
||||
<span id="statusText">加载中...</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="refreshBtn" onclick="refreshAll()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition flex items-center gap-1.5">刷新</button>
|
||||
<span id="headerUser" class="text-gray-500 dark:text-gray-400 text-sm">...</span>
|
||||
</div>
|
||||
</header>
|
||||
<p class="text-[var(--text-dim)] text-xs mt-2">告警详情通过 Telegram 推送;此处仅展示汇总状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Category Breakdown -->
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<h2 class="text-white font-semibold mb-4 text-sm">分类分布</h2>
|
||||
<div id="categoryBars" class="space-y-3">
|
||||
<div class="text-[var(--text-muted)] text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<!-- Recent Sync + Recent Audit -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Recent Sync Logs -->
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white font-semibold text-sm">最近同步</h2>
|
||||
<a href="/app/push.html" class="text-brand-light text-xs hover:underline">查看全部</a>
|
||||
</div>
|
||||
<div id="recentSyncs" class="text-[var(--text-secondary)] text-sm">加载中...</div>
|
||||
</div>
|
||||
<!-- Recent Audit -->
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white font-semibold text-sm">最近活动</h2>
|
||||
<a href="/app/audit.html" class="text-brand-light text-xs hover:underline">查看全部</a>
|
||||
</div>
|
||||
<div id="recentActivity" class="text-[var(--text-secondary)] text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm mb-1">服务器总数</div>
|
||||
<div class="text-3xl font-bold text-white" id="statTotal">--</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm mb-1">在线</div>
|
||||
<div class="text-3xl font-bold text-green-400" id="statOnline">--</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm mb-1">离线</div>
|
||||
<div class="text-3xl font-bold text-red-400" id="statOffline">--</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm mb-1">告警中</div>
|
||||
<div class="text-3xl font-bold text-yellow-400" id="statAlerts">--</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('index');
|
||||
let _ws = null;
|
||||
let _wsRetry = 0;
|
||||
let _stats = null;
|
||||
|
||||
<!-- Run status + Category Pie -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="lg:col-span-2">
|
||||
<div id="statusPanel" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<h2 class="text-white font-semibold text-sm mb-2">运行状态</h2>
|
||||
<div id="statusSummary" class="flex items-center gap-2 text-gray-400 dark:text-gray-500 text-sm">
|
||||
<span id="statusDot" class="inline-block w-2 h-2 rounded-full bg-slate-600 shrink-0"></span>
|
||||
<span id="statusText">加载中...</span>
|
||||
</div>
|
||||
<p class="text-gray-300 dark:text-gray-600 text-xs mt-2">告警详情通过 Telegram 推送;此处仅展示汇总状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Category Breakdown -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
<h2 class="text-white font-semibold mb-4 text-sm">分类分布</h2>
|
||||
<div id="categoryBars" class="space-y-3">
|
||||
<div class="text-gray-400 dark:text-gray-500 text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// ── Dashboard Stats ──
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/servers/stats');
|
||||
if (!res) return;
|
||||
_stats = await res.json();
|
||||
document.getElementById('statTotal').textContent = _stats.total || 0;
|
||||
document.getElementById('statOnline').textContent = _stats.online || 0;
|
||||
document.getElementById('statOffline').textContent = _stats.offline || 0;
|
||||
document.getElementById('statAlerts').textContent = _stats.alerts || 0;
|
||||
renderCategories(_stats.categories || {});
|
||||
updateStatusPanel();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
<!-- Recent Sync + Recent Audit -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Recent Sync Logs -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white font-semibold text-sm">最近同步</h2>
|
||||
<a href="/app/push.html" class="text-brand-light text-xs hover:underline">查看全部</a>
|
||||
</div>
|
||||
<div id="recentSyncs" class="text-gray-500 dark:text-gray-400 text-sm">加载中...</div>
|
||||
</div>
|
||||
<!-- Recent Audit -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white font-semibold text-sm">最近活动</h2>
|
||||
<a href="/app/audit.html" class="text-brand-light text-xs hover:underline">查看全部</a>
|
||||
</div>
|
||||
<div id="recentActivity" class="text-gray-500 dark:text-gray-400 text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
function updateStatusPanel() {
|
||||
const n = (_stats && _stats.alerts) ? _stats.alerts : 0;
|
||||
const panel = document.getElementById('statusPanel');
|
||||
const dot = document.getElementById('statusDot');
|
||||
const text = document.getElementById('statusText');
|
||||
if (!panel || !dot || !text) return;
|
||||
if (n > 0) {
|
||||
panel.className = 'bg-[var(--bg-card)] rounded-xl border border-yellow-700/40 p-4';
|
||||
dot.className = 'inline-block w-2 h-2 rounded-full bg-yellow-400 shrink-0';
|
||||
text.className = 'text-yellow-200/90 text-sm';
|
||||
text.textContent = `当前有 ${n} 台服务器处于告警状态(详见 Telegram)`;
|
||||
} else {
|
||||
panel.className = 'bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4';
|
||||
dot.className = 'inline-block w-2 h-2 rounded-full bg-green-400 shrink-0';
|
||||
text.className = 'text-[var(--text-secondary)] text-sm';
|
||||
text.textContent = '所有服务器运行正常';
|
||||
}
|
||||
}
|
||||
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('index');
|
||||
let _ws = null;
|
||||
let _wsRetry = 0;
|
||||
let _stats = null;
|
||||
function renderCategories(categories) {
|
||||
const entries = Object.entries(categories);
|
||||
if (!entries.length) {
|
||||
document.getElementById('categoryBars').innerHTML = '<div class="text-[var(--text-muted)] text-sm">暂无分类数据</div>';
|
||||
return;
|
||||
}
|
||||
const total = entries.reduce((s,[,v]) => s+v, 0);
|
||||
const colors = ['bg-brand','bg-green-500','bg-yellow-500','bg-purple-500','bg-cyan-500','bg-pink-500'];
|
||||
document.getElementById('categoryBars').innerHTML = entries.map(([cat,count],i) => {
|
||||
const pct = Math.round(count/total*100);
|
||||
return `<div>
|
||||
<div class="flex justify-between text-xs mb-1"><span class="text-slate-300">${esc(cat||'未分类')}</span><span class="text-[var(--text-muted)]">${count}台 (${pct}%)</span></div>
|
||||
<div class="w-full bg-[var(--bg-surface)] rounded-full h-2"><div class="${colors[i%colors.length]} h-2 rounded-full transition-all" style="width:${pct}%"></div></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Dashboard Stats ──
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/servers/stats');
|
||||
if (!res) return;
|
||||
_stats = await res.json();
|
||||
document.getElementById('statTotal').textContent = _stats.total || 0;
|
||||
document.getElementById('statOnline').textContent = _stats.online || 0;
|
||||
document.getElementById('statOffline').textContent = _stats.offline || 0;
|
||||
document.getElementById('statAlerts').textContent = _stats.alerts || 0;
|
||||
renderCategories(_stats.categories || {});
|
||||
updateStatusPanel();
|
||||
} catch(e) {}
|
||||
// ── Recent Syncs ──
|
||||
async function loadSyncs() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/servers/logs?limit=8');
|
||||
if (!res) return;
|
||||
const logs = await res.json();
|
||||
if (!logs.length) {
|
||||
document.getElementById('recentSyncs').innerHTML = '<div class="text-[var(--text-muted)] text-sm">暂无同步记录</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('recentSyncs').innerHTML = logs.map(l => {
|
||||
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
|
||||
const icon = l.status==='success'?'✅':l.status==='failed'?'❌':'⏳';
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-[var(--border)] last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-slate-300 text-xs">${esc(l.server_name||'#'+l.server_id)}</span><span class="${sc} text-xs">${esc(l.status)}</span></div>
|
||||
<span class="text-[var(--text-dim)] text-xs">${fmtTime(l.started_at)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('recentSyncs').textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusPanel() {
|
||||
const n = (_stats && _stats.alerts) ? _stats.alerts : 0;
|
||||
const panel = document.getElementById('statusPanel');
|
||||
const dot = document.getElementById('statusDot');
|
||||
const text = document.getElementById('statusText');
|
||||
if (!panel || !dot || !text) return;
|
||||
if (n > 0) {
|
||||
panel.className = 'bg-white dark:bg-gray-900 rounded-xl border border-yellow-700/40 p-4';
|
||||
dot.className = 'inline-block w-2 h-2 rounded-full bg-yellow-400 shrink-0';
|
||||
text.className = 'text-yellow-200/90 text-sm';
|
||||
text.textContent = `当前有 ${n} 台服务器处于告警状态(详见 Telegram)`;
|
||||
} else {
|
||||
panel.className = 'bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4';
|
||||
dot.className = 'inline-block w-2 h-2 rounded-full bg-green-400 shrink-0';
|
||||
text.className = 'text-gray-500 dark:text-gray-400 text-sm';
|
||||
text.textContent = '所有服务器运行正常';
|
||||
}
|
||||
// ── Recent Activity ──
|
||||
async function loadActivity() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/audit/?limit=8');
|
||||
if (!res) return;
|
||||
const data = await res.json();
|
||||
const audits = data.items || data;
|
||||
if (!audits.length) {
|
||||
document.getElementById('recentActivity').innerHTML = '<div class="text-[var(--text-muted)] text-sm">暂无活动</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('recentActivity').innerHTML = audits.map(a => {
|
||||
const icon = a.action.includes('delete')?'🔴':a.action.includes('create')?'🔵':a.action.includes('login')?'🟢':'⚪';
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-[var(--border)] last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-brand-light text-xs">${esc(a.admin_username||'system')}</span><span class="text-[var(--text-muted)] text-xs">${esc(a.action)}</span></div>
|
||||
<span class="text-[var(--text-dim)] text-xs">${fmtTime(a.created_at)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('recentActivity').textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
function renderCategories(categories) {
|
||||
const entries = Object.entries(categories);
|
||||
if (!entries.length) {
|
||||
document.getElementById('categoryBars').innerHTML = '<div class="text-gray-400 dark:text-gray-500 text-sm">暂无分类数据</div>';
|
||||
return;
|
||||
}
|
||||
const total = entries.reduce((s,[,v]) => s+v, 0);
|
||||
const colors = ['bg-brand','bg-green-500','bg-yellow-500','bg-purple-500','bg-cyan-500','bg-pink-500'];
|
||||
document.getElementById('categoryBars').innerHTML = entries.map(([cat,count],i) => {
|
||||
const pct = Math.round(count/total*100);
|
||||
return `<div>
|
||||
<div class="flex justify-between text-xs mb-1"><span class="text-slate-300">${esc(cat||'未分类')}</span><span class="text-gray-400 dark:text-gray-500">${count}台 (${pct}%)</span></div>
|
||||
<div class="w-full bg-gray-100 dark:bg-gray-800 rounded-full h-2"><div class="${colors[i%colors.length]} h-2 rounded-full transition-all" style="width:${pct}%"></div></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
// ── Helpers ──
|
||||
function fmtTime(t) { if (!t) return ''; var d = new Date(t); if (isNaN(d.getTime())) { d = new Date(t + 'Z'); } return d.toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
|
||||
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
loadDashboard();
|
||||
loadActivity();
|
||||
loadSyncs();
|
||||
connectWS();
|
||||
function connectWS() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
_ws = new WebSocket(`${proto}://${location.host}/ws/alerts?token=${token}`);
|
||||
_ws.onopen = () => { _wsRetry = 0; };
|
||||
_ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'ping') { _ws.send('pong'); return; }
|
||||
// WS 仅用于刷新仪表盘状态;主动通知走 Telegram
|
||||
loadDashboard();
|
||||
} catch(ex) {}
|
||||
};
|
||||
_ws.onclose = () => {
|
||||
const delay = Math.min(1000 * Math.pow(2, _wsRetry), 30000);
|
||||
_wsRetry++;
|
||||
setTimeout(connectWS, delay);
|
||||
};
|
||||
_ws.onerror = () => { _ws.close(); };
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
const btn = document.getElementById('refreshBtn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>刷新中';
|
||||
}
|
||||
Promise.all([loadDashboard(), loadActivity(), loadSyncs()]).finally(() => {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '刷新';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Recent Syncs ──
|
||||
async function loadSyncs() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/servers/logs?limit=8');
|
||||
if (!res) return;
|
||||
const logs = await res.json();
|
||||
if (!logs.length) {
|
||||
document.getElementById('recentSyncs').innerHTML = '<div class="text-gray-400 dark:text-gray-500 text-sm">暂无同步记录</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('recentSyncs').innerHTML = logs.map(l => {
|
||||
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
|
||||
const icon = l.status==='success'?'✅':l.status==='failed'?'❌':'⏳';
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-800 last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-slate-300 text-xs">${esc(l.server_name||'#'+l.server_id)}</span><span class="${sc} text-xs">${esc(l.status)}</span></div>
|
||||
<span class="text-gray-300 dark:text-gray-600 text-xs">${fmtTime(l.started_at)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('recentSyncs').textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recent Activity ──
|
||||
async function loadActivity() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/audit/?limit=8');
|
||||
if (!res) return;
|
||||
const data = await res.json();
|
||||
const audits = data.items || data;
|
||||
if (!audits.length) {
|
||||
document.getElementById('recentActivity').innerHTML = '<div class="text-gray-400 dark:text-gray-500 text-sm">暂无活动</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('recentActivity').innerHTML = audits.map(a => {
|
||||
const icon = a.action.includes('delete')?'🔴':a.action.includes('create')?'🔵':a.action.includes('login')?'🟢':'⚪';
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-800 last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-brand-light text-xs">${esc(a.admin_username||'system')}</span><span class="text-gray-400 dark:text-gray-500 text-xs">${esc(a.action)}</span></div>
|
||||
<span class="text-gray-300 dark:text-gray-600 text-xs">${fmtTime(a.created_at)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('recentActivity').textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function fmtTime(t) { if (!t) return ''; var d = new Date(t); if (isNaN(d.getTime())) { d = new Date(t + 'Z'); } return d.toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
|
||||
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
loadDashboard();
|
||||
loadActivity();
|
||||
loadSyncs();
|
||||
connectWS();
|
||||
function connectWS() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
_ws = new WebSocket(`${proto}://${location.host}/ws/alerts?token=${token}`);
|
||||
_ws.onopen = () => { _wsRetry = 0; };
|
||||
_ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'ping') { _ws.send('pong'); return; }
|
||||
// WS 仅用于刷新仪表盘状态;主动通知走 Telegram
|
||||
loadDashboard();
|
||||
} catch(ex) {}
|
||||
};
|
||||
_ws.onclose = () => {
|
||||
const delay = Math.min(1000 * Math.pow(2, _wsRetry), 30000);
|
||||
_wsRetry++;
|
||||
setTimeout(connectWS, delay);
|
||||
};
|
||||
_ws.onerror = () => { _ws.close(); };
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
const btn = document.getElementById('refreshBtn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>刷新中';
|
||||
}
|
||||
Promise.all([loadDashboard(), loadActivity(), loadSyncs()]).finally(() => {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '刷新';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-refresh every 30s
|
||||
setInterval(() => { loadDashboard(); loadSyncs(); }, 30000);
|
||||
</script>
|
||||
// Auto-refresh every 30s
|
||||
setInterval(() => { loadDashboard(); loadSyncs(); }, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+618
-618
File diff suppressed because it is too large
Load Diff
+317
-317
@@ -1,348 +1,348 @@
|
||||
<!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>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nexus — 登录</title>
|
||||
|
||||
<!-- Tailwind CSS v4 CDN -->
|
||||
<script src="/app/vendor/tailwindcss-browser.js"></script>
|
||||
<script src="/app/vendor/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<!-- Tailwind CSS v4 CDN -->
|
||||
<script src="/app/vendor/tailwindcss-browser.js"></script>
|
||||
<script src="/app/vendor/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
|
||||
<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);
|
||||
--color-surface: oklch(98.5% 0.002 250);
|
||||
}
|
||||
</style>
|
||||
<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);
|
||||
--color-surface: oklch(98.5% 0.002 250);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-950 min-h-screen flex items-center justify-center p-4">
|
||||
<body class="bg-[var(--bg-page)] min-h-screen flex items-center justify-center p-4">
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<div class="w-full max-w-md">
|
||||
|
||||
<!-- Logo / Brand -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-brand/20 mb-4">
|
||||
<svg class="w-8 h-8 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white">Nexus</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">服务器运维管理平台</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Card -->
|
||||
<div id="loginCard" class="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-700 p-8 shadow-xl">
|
||||
|
||||
<!-- Step 1: Password -->
|
||||
<div id="stepPassword">
|
||||
<h2 class="text-lg font-semibold text-white mb-6">登录到您的账户</h2>
|
||||
|
||||
<form id="loginForm" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">用户名</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username"
|
||||
class="w-full px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="请输入用户名">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">密码</label>
|
||||
<div class="relative">
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||
class="w-full px-4 py-3 pr-12 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="••••••••">
|
||||
<button type="button" id="togglePwd" tabindex="-1"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 hover:text-slate-300 transition p-1">
|
||||
<!-- Eye icon (show) -->
|
||||
<svg id="eyeShow" 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
<!-- Eye-off icon (hide) -->
|
||||
<svg id="eyeHide" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="loginBtn"
|
||||
class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: TOTP (hidden by default) -->
|
||||
<div id="stepTotp" class="hidden">
|
||||
<h2 class="text-lg font-semibold text-white mb-2">两步验证</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mb-6">请输入验证器应用中的 6 位数字</p>
|
||||
|
||||
<form id="totpForm" class="space-y-5">
|
||||
<div>
|
||||
<input type="text" id="totpCode" name="totp_code" required maxlength="6" inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
|
||||
class="w-full px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl text-white text-center text-2xl tracking-[0.5em] placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="000000">
|
||||
</div>
|
||||
|
||||
<button type="submit" id="totpBtn"
|
||||
class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
验证
|
||||
</button>
|
||||
|
||||
<button type="button" id="backBtn"
|
||||
class="w-full py-2 text-gray-500 dark:text-gray-400 hover:text-white text-sm transition">
|
||||
← 返回
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div id="errorMsg" class="hidden mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm text-center"></div>
|
||||
|
||||
<!-- Lockout Warning -->
|
||||
<div id="lockoutMsg" class="hidden mt-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl text-amber-400 text-sm text-center"></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="text-center text-gray-300 dark:text-gray-600 text-xs mt-6">Nexus v6.0 · 安全连接</p>
|
||||
<!-- Logo / Brand -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-brand/20 mb-4">
|
||||
<svg class="w-8 h-8 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white">Nexus</h1>
|
||||
<p class="text-[var(--text-secondary)] text-sm mt-1">服务器运维管理平台</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = window.location.origin + '/api/auth';
|
||||
let pendingUsername = '';
|
||||
let pendingPassword = '';
|
||||
let failCount = 0;
|
||||
const MAX_FAIL_DISPLAY = 5; // Show warning after this many failures
|
||||
<!-- Login Card -->
|
||||
<div id="loginCard" class="bg-[var(--bg-card)] rounded-2xl border border-[var(--border)] p-8 shadow-xl">
|
||||
|
||||
// ── Password Visibility Toggle ──
|
||||
document.getElementById('togglePwd').addEventListener('click', () => {
|
||||
const pwdInput = document.getElementById('password');
|
||||
const eyeShow = document.getElementById('eyeShow');
|
||||
const eyeHide = document.getElementById('eyeHide');
|
||||
if (pwdInput.type === 'password') {
|
||||
pwdInput.type = 'text';
|
||||
eyeShow.classList.add('hidden');
|
||||
eyeHide.classList.remove('hidden');
|
||||
} else {
|
||||
pwdInput.type = 'password';
|
||||
eyeShow.classList.remove('hidden');
|
||||
eyeHide.classList.add('hidden');
|
||||
}
|
||||
<!-- Step 1: Password -->
|
||||
<div id="stepPassword">
|
||||
<h2 class="text-lg font-semibold text-white mb-6">登录到您的账户</h2>
|
||||
|
||||
<form id="loginForm" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">用户名</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username"
|
||||
class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="请输入用户名">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">密码</label>
|
||||
<div class="relative">
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||
class="w-full px-4 py-3 pr-12 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="••••••••">
|
||||
<button type="button" id="togglePwd" tabindex="-1"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] hover:text-slate-300 transition p-1">
|
||||
<!-- Eye icon (show) -->
|
||||
<svg id="eyeShow" 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
<!-- Eye-off icon (hide) -->
|
||||
<svg id="eyeHide" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="loginBtn"
|
||||
class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: TOTP (hidden by default) -->
|
||||
<div id="stepTotp" class="hidden">
|
||||
<h2 class="text-lg font-semibold text-white mb-2">两步验证</h2>
|
||||
<p class="text-[var(--text-secondary)] text-sm mb-6">请输入验证器应用中的 6 位数字</p>
|
||||
|
||||
<form id="totpForm" class="space-y-5">
|
||||
<div>
|
||||
<input type="text" id="totpCode" name="totp_code" required maxlength="6" inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
|
||||
class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-center text-2xl tracking-[0.5em] placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="000000">
|
||||
</div>
|
||||
|
||||
<button type="submit" id="totpBtn"
|
||||
class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
验证
|
||||
</button>
|
||||
|
||||
<button type="button" id="backBtn"
|
||||
class="w-full py-2 text-[var(--text-secondary)] hover:text-white text-sm transition">
|
||||
← 返回
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div id="errorMsg" class="hidden mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm text-center"></div>
|
||||
|
||||
<!-- Lockout Warning -->
|
||||
<div id="lockoutMsg" class="hidden mt-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl text-amber-400 text-sm text-center"></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="text-center text-[var(--text-dim)] text-xs mt-6">Nexus v6.0 · 安全连接</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = window.location.origin + '/api/auth';
|
||||
let pendingUsername = '';
|
||||
let pendingPassword = '';
|
||||
let failCount = 0;
|
||||
const MAX_FAIL_DISPLAY = 5; // Show warning after this many failures
|
||||
|
||||
// ── Password Visibility Toggle ──
|
||||
document.getElementById('togglePwd').addEventListener('click', () => {
|
||||
const pwdInput = document.getElementById('password');
|
||||
const eyeShow = document.getElementById('eyeShow');
|
||||
const eyeHide = document.getElementById('eyeHide');
|
||||
if (pwdInput.type === 'password') {
|
||||
pwdInput.type = 'text';
|
||||
eyeShow.classList.add('hidden');
|
||||
eyeHide.classList.remove('hidden');
|
||||
} else {
|
||||
pwdInput.type = 'password';
|
||||
eyeShow.classList.remove('hidden');
|
||||
eyeHide.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 1: Password Login ──
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
hideLockout();
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
if (!username || !password) return;
|
||||
|
||||
const btn = document.getElementById('loginBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '登录中...';
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
// ── Step 1: Password Login ──
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
hideLockout();
|
||||
const data = await res.json();
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
if (!username || !password) return;
|
||||
if (res.status === 202 && data.detail === '需要TOTP验证码') {
|
||||
// TOTP required — show step 2
|
||||
pendingUsername = username;
|
||||
pendingPassword = password;
|
||||
showTotpStep();
|
||||
} else if (res.status === 429) {
|
||||
// Account locked
|
||||
failCount = MAX_FAIL_DISPLAY;
|
||||
showError(data.detail || '登录尝试过多,账户已临时锁定');
|
||||
showLockout('账户已临时锁定,请15分钟后再试。');
|
||||
} else if (!res.ok) {
|
||||
failCount++;
|
||||
showError(data.detail || '登录失败');
|
||||
|
||||
const btn = document.getElementById('loginBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '登录中...';
|
||||
// Show lockout warning after repeated failures
|
||||
if (failCount >= MAX_FAIL_DISPLAY) {
|
||||
showLockout(`连续登录失败 ${failCount} 次。多次失败后账户可能被临时锁定,请确认用户名和密码。`);
|
||||
} else if (failCount >= 3) {
|
||||
showLockout(`已失败 ${failCount} 次,请检查输入。`);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
// Shake animation on error
|
||||
const card = document.getElementById('loginCard');
|
||||
card.classList.add('animate-shake');
|
||||
setTimeout(() => card.classList.remove('animate-shake'), 500);
|
||||
} else {
|
||||
// Success — store tokens and redirect
|
||||
failCount = 0;
|
||||
onLoginSuccess(data);
|
||||
}
|
||||
} catch (err) {
|
||||
showError('网络错误,请检查连接');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '登录';
|
||||
}
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
// ── Step 2: TOTP Verification ──
|
||||
document.getElementById('totpForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
|
||||
if (res.status === 202 && data.detail === '需要TOTP验证码') {
|
||||
// TOTP required — show step 2
|
||||
pendingUsername = username;
|
||||
pendingPassword = password;
|
||||
showTotpStep();
|
||||
} else if (res.status === 429) {
|
||||
// Account locked
|
||||
failCount = MAX_FAIL_DISPLAY;
|
||||
showError(data.detail || '登录尝试过多,账户已临时锁定');
|
||||
showLockout('账户已临时锁定,请15分钟后再试。');
|
||||
} else if (!res.ok) {
|
||||
failCount++;
|
||||
showError(data.detail || '登录失败');
|
||||
const totpCode = document.getElementById('totpCode').value.trim();
|
||||
if (totpCode.length !== 6) return;
|
||||
|
||||
// Show lockout warning after repeated failures
|
||||
if (failCount >= MAX_FAIL_DISPLAY) {
|
||||
showLockout(`连续登录失败 ${failCount} 次。多次失败后账户可能被临时锁定,请确认用户名和密码。`);
|
||||
} else if (failCount >= 3) {
|
||||
showLockout(`已失败 ${failCount} 次,请检查输入。`);
|
||||
}
|
||||
const btn = document.getElementById('totpBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '验证中...';
|
||||
|
||||
// Shake animation on error
|
||||
const card = document.getElementById('loginCard');
|
||||
card.classList.add('animate-shake');
|
||||
setTimeout(() => card.classList.remove('animate-shake'), 500);
|
||||
} else {
|
||||
// Success — store tokens and redirect
|
||||
failCount = 0;
|
||||
onLoginSuccess(data);
|
||||
}
|
||||
} catch (err) {
|
||||
showError('网络错误,请检查连接');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '登录';
|
||||
}
|
||||
try {
|
||||
const res = await fetch(API + '/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: pendingUsername,
|
||||
password: pendingPassword,
|
||||
totp_code: totpCode,
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Step 2: TOTP Verification ──
|
||||
document.getElementById('totpForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
const data = await res.json();
|
||||
|
||||
const totpCode = document.getElementById('totpCode').value.trim();
|
||||
if (totpCode.length !== 6) return;
|
||||
|
||||
const btn = document.getElementById('totpBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '验证中...';
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: pendingUsername,
|
||||
password: pendingPassword,
|
||||
totp_code: totpCode,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
failCount++;
|
||||
showError(data.detail || '验证码错误');
|
||||
if (failCount >= 3) {
|
||||
showLockout(`已失败 ${failCount} 次,请确认验证码正确。`);
|
||||
}
|
||||
document.getElementById('totpCode').value = '';
|
||||
document.getElementById('totpCode').focus();
|
||||
} else {
|
||||
failCount = 0;
|
||||
onLoginSuccess(data);
|
||||
}
|
||||
} catch (err) {
|
||||
showError('网络错误,请检查连接');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '验证';
|
||||
}
|
||||
});
|
||||
|
||||
// Back button
|
||||
document.getElementById('backBtn').addEventListener('click', () => {
|
||||
// Clear sensitive data when going back
|
||||
pendingUsername = '';
|
||||
pendingPassword = '';
|
||||
showPasswordStep();
|
||||
});
|
||||
|
||||
// Auto-focus TOTP input
|
||||
function showTotpStep() {
|
||||
document.getElementById('stepPassword').classList.add('hidden');
|
||||
document.getElementById('stepTotp').classList.remove('hidden');
|
||||
document.getElementById('totpCode').value = '';
|
||||
document.getElementById('totpCode').focus();
|
||||
if (!res.ok) {
|
||||
failCount++;
|
||||
showError(data.detail || '验证码错误');
|
||||
if (failCount >= 3) {
|
||||
showLockout(`已失败 ${failCount} 次,请确认验证码正确。`);
|
||||
}
|
||||
document.getElementById('totpCode').value = '';
|
||||
document.getElementById('totpCode').focus();
|
||||
} else {
|
||||
failCount = 0;
|
||||
onLoginSuccess(data);
|
||||
}
|
||||
} catch (err) {
|
||||
showError('网络错误,请检查连接');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '验证';
|
||||
}
|
||||
});
|
||||
|
||||
function showPasswordStep() {
|
||||
document.getElementById('stepTotp').classList.add('hidden');
|
||||
document.getElementById('stepPassword').classList.remove('hidden');
|
||||
document.getElementById('password').focus();
|
||||
// Back button
|
||||
document.getElementById('backBtn').addEventListener('click', () => {
|
||||
// Clear sensitive data when going back
|
||||
pendingUsername = '';
|
||||
pendingPassword = '';
|
||||
showPasswordStep();
|
||||
});
|
||||
|
||||
// Auto-focus TOTP input
|
||||
function showTotpStep() {
|
||||
document.getElementById('stepPassword').classList.add('hidden');
|
||||
document.getElementById('stepTotp').classList.remove('hidden');
|
||||
document.getElementById('totpCode').value = '';
|
||||
document.getElementById('totpCode').focus();
|
||||
}
|
||||
|
||||
function showPasswordStep() {
|
||||
document.getElementById('stepTotp').classList.add('hidden');
|
||||
document.getElementById('stepPassword').classList.remove('hidden');
|
||||
document.getElementById('password').focus();
|
||||
}
|
||||
|
||||
// ── Success Handler ──
|
||||
function onLoginSuccess(data) {
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
// Refresh token is now in HttpOnly cookie — not accessible from JS
|
||||
localStorage.setItem('admin', JSON.stringify(data.admin));
|
||||
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
|
||||
|
||||
// Clear sensitive data from memory
|
||||
pendingUsername = '';
|
||||
pendingPassword = '';
|
||||
|
||||
window.location.href = '/app/index.html';
|
||||
}
|
||||
|
||||
// ── Error Display ──
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('errorMsg').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Lockout Warning ──
|
||||
function showLockout(msg) {
|
||||
const el = document.getElementById('lockoutMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideLockout() {
|
||||
document.getElementById('lockoutMsg').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Check existing session ──
|
||||
(function checkSession() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const expires = localStorage.getItem('token_expires');
|
||||
if (token && expires && Date.now() < parseInt(expires)) {
|
||||
window.location.href = '/app/index.html';
|
||||
return;
|
||||
}
|
||||
// Access token expired — try refresh via HttpOnly cookie (auto-sent by browser)
|
||||
fetch(window.location.origin + '/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.success) {
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
|
||||
window.location.href = '/app/index.html';
|
||||
}
|
||||
}).catch(() => {});
|
||||
})();
|
||||
|
||||
// ── Success Handler ──
|
||||
function onLoginSuccess(data) {
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
// Refresh token is now in HttpOnly cookie — not accessible from JS
|
||||
localStorage.setItem('admin', JSON.stringify(data.admin));
|
||||
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
|
||||
// S-05: Show message from URL parameter (e.g., after password change redirect)
|
||||
(function() {
|
||||
const msg = new URLSearchParams(window.location.search).get('msg');
|
||||
if (msg === 'password_changed') {
|
||||
const el = document.getElementById('lockoutMsg');
|
||||
el.textContent = '✅ 密码已修改,请重新登录。';
|
||||
el.className = 'mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-xl text-green-400 text-sm text-center';
|
||||
el.classList.remove('hidden');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
// Clear sensitive data from memory
|
||||
pendingUsername = '';
|
||||
pendingPassword = '';
|
||||
|
||||
window.location.href = '/app/index.html';
|
||||
}
|
||||
|
||||
// ── Error Display ──
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('errorMsg').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Lockout Warning ──
|
||||
function showLockout(msg) {
|
||||
const el = document.getElementById('lockoutMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideLockout() {
|
||||
document.getElementById('lockoutMsg').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Check existing session ──
|
||||
(function checkSession() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const expires = localStorage.getItem('token_expires');
|
||||
if (token && expires && Date.now() < parseInt(expires)) {
|
||||
window.location.href = '/app/index.html';
|
||||
return;
|
||||
}
|
||||
// Access token expired — try refresh via HttpOnly cookie (auto-sent by browser)
|
||||
fetch(window.location.origin + '/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.success) {
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
|
||||
window.location.href = '/app/index.html';
|
||||
}
|
||||
}).catch(() => {});
|
||||
})();
|
||||
|
||||
// S-05: Show message from URL parameter (e.g., after password change redirect)
|
||||
(function() {
|
||||
const msg = new URLSearchParams(window.location.search).get('msg');
|
||||
if (msg === 'password_changed') {
|
||||
const el = document.getElementById('lockoutMsg');
|
||||
el.textContent = '✅ 密码已修改,请重新登录。';
|
||||
el.className = 'mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-xl text-green-400 text-sm text-center';
|
||||
el.classList.remove('hidden');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+868
-868
File diff suppressed because it is too large
Load Diff
+76
-76
@@ -2,84 +2,84 @@
|
||||
<script src="/app/vendor/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
|
||||
<div class="flex items-center gap-3">
|
||||
<select id="statusFilter" onchange="loadRetries()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="running">执行中</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="success">成功</option>
|
||||
</select>
|
||||
<button onclick="loadRetries()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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>
|
||||
<div class="flex items-center gap-3">
|
||||
<select id="statusFilter" onchange="loadRetries()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="running">执行中</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="success">成功</option>
|
||||
</select>
|
||||
<button onclick="loadRetries()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="retryList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('retries');
|
||||
let _retriesCache=[];
|
||||
|
||||
async function loadRetries(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/retries/');if(!r)return;
|
||||
let jobs=await r.json();
|
||||
_retriesCache=jobs;
|
||||
|
||||
// Client-side status filter
|
||||
const statusFilter=document.getElementById('statusFilter').value;
|
||||
if(statusFilter)jobs=jobs.filter(j=>j.status===statusFilter);
|
||||
|
||||
document.getElementById('retryList').innerHTML=jobs.length?jobs.map(j=>{
|
||||
const statusColor=j.status==='pending'?'text-yellow-400':j.status==='running'?'text-blue-400':j.status==='failed'?'text-red-400':'text-green-400';
|
||||
const statusIcon=j.status==='pending'?'⏳':j.status==='running'?'🔄':j.status==='failed'?'❌':'✅';
|
||||
const retryPct=Math.min((j.retry_count/j.max_retries)*100,100);
|
||||
const canRetry=j.status==='failed'||j.status==='pending';
|
||||
return `<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm">${statusIcon}</span>
|
||||
<span class="text-white font-medium">${esc(j.server_name||'#'+j.server_id)}</span>
|
||||
<span class="${statusColor} text-xs px-2 py-0.5 rounded ${j.status==='failed'?'bg-red-400/10':j.status==='pending'?'bg-yellow-400/10':j.status==='success'?'bg-green-400/10':'bg-blue-400/10'}">${esc(j.status)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-[var(--text-secondary)]">重试 ${j.retry_count}/${j.max_retries}</span>
|
||||
${canRetry?`<button onclick="retryJob(${j.id})" class="text-brand-light text-xs hover:underline px-2 py-1 bg-brand/10 rounded">重试</button>`:''}
|
||||
<button onclick="deleteJob(${j.id})" class="text-red-400 text-xs hover:underline px-2 py-1 bg-red-400/10 rounded">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="retryList" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-8">加载中...</div></div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('retries');
|
||||
let _retriesCache=[];
|
||||
<div class="mt-2 text-xs text-[var(--text-muted)]">${esc(j.source_path)} → ${esc(j.target_path||'')}</div>
|
||||
${j.operator?`<div class="mt-1 text-xs text-[var(--text-dim)]">操作人: ${esc(j.operator)}</div>`:''}
|
||||
${j.last_error?`<div class="mt-1 text-xs text-red-400/70 truncate" title="${esc(j.last_error)}">${esc(j.last_error.substring(0,100))}</div>`:''}
|
||||
<div class="mt-2 w-full bg-[var(--bg-surface)] rounded-full h-1"><div class="h-1 rounded-full ${retryPct>=80?'bg-red-400':retryPct>=50?'bg-yellow-400':'bg-brand'}" style="width:${retryPct}%"></div></div>
|
||||
${j.next_retry_at?`<div class="mt-1 text-xs text-[var(--text-dim)]">下次重试: ${fmtTime(j.next_retry_at)}</div>`:''}
|
||||
</div>`}).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无重试任务</div>';
|
||||
}catch(e){document.getElementById('retryList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
async function loadRetries(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/retries/');if(!r)return;
|
||||
let jobs=await r.json();
|
||||
_retriesCache=jobs;
|
||||
async function retryJob(id){
|
||||
const r=await apiFetch(API+'/retries/'+id+'/retry',{method:'POST'});
|
||||
if(r&&r.ok)toast('success','重试任务已触发');else toast('error','触发重试失败');
|
||||
loadRetries();
|
||||
}
|
||||
|
||||
// Client-side status filter
|
||||
const statusFilter=document.getElementById('statusFilter').value;
|
||||
if(statusFilter)jobs=jobs.filter(j=>j.status===statusFilter);
|
||||
async function deleteJob(id){
|
||||
if(!confirm('确定删除此重试任务?'))return;
|
||||
const r=await apiFetch(API+'/retries/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','重试任务已删除');else toast('error','删除失败');
|
||||
loadRetries();
|
||||
}
|
||||
|
||||
document.getElementById('retryList').innerHTML=jobs.length?jobs.map(j=>{
|
||||
const statusColor=j.status==='pending'?'text-yellow-400':j.status==='running'?'text-blue-400':j.status==='failed'?'text-red-400':'text-green-400';
|
||||
const statusIcon=j.status==='pending'?'⏳':j.status==='running'?'🔄':j.status==='failed'?'❌':'✅';
|
||||
const retryPct=Math.min((j.retry_count/j.max_retries)*100,100);
|
||||
const canRetry=j.status==='failed'||j.status==='pending';
|
||||
return `<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm">${statusIcon}</span>
|
||||
<span class="text-white font-medium">${esc(j.server_name||'#'+j.server_id)}</span>
|
||||
<span class="${statusColor} text-xs px-2 py-0.5 rounded ${j.status==='failed'?'bg-red-400/10':j.status==='pending'?'bg-yellow-400/10':j.status==='success'?'bg-green-400/10':'bg-blue-400/10'}">${esc(j.status)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">重试 ${j.retry_count}/${j.max_retries}</span>
|
||||
${canRetry?`<button onclick="retryJob(${j.id})" class="text-brand-light text-xs hover:underline px-2 py-1 bg-brand/10 rounded">重试</button>`:''}
|
||||
<button onclick="deleteJob(${j.id})" class="text-red-400 text-xs hover:underline px-2 py-1 bg-red-400/10 rounded">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">${esc(j.source_path)} → ${esc(j.target_path||'')}</div>
|
||||
${j.operator?`<div class="mt-1 text-xs text-gray-300 dark:text-gray-600">操作人: ${esc(j.operator)}</div>`:''}
|
||||
${j.last_error?`<div class="mt-1 text-xs text-red-400/70 truncate" title="${esc(j.last_error)}">${esc(j.last_error.substring(0,100))}</div>`:''}
|
||||
<div class="mt-2 w-full bg-gray-100 dark:bg-gray-800 rounded-full h-1"><div class="h-1 rounded-full ${retryPct>=80?'bg-red-400':retryPct>=50?'bg-yellow-400':'bg-brand'}" style="width:${retryPct}%"></div></div>
|
||||
${j.next_retry_at?`<div class="mt-1 text-xs text-gray-300 dark:text-gray-600">下次重试: ${fmtTime(j.next_retry_at)}</div>`:''}
|
||||
</div>`}).join(''):'<div class="text-gray-400 dark:text-gray-500 text-center py-8">暂无重试任务</div>';
|
||||
}catch(e){document.getElementById('retryList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
async function retryJob(id){
|
||||
const r=await apiFetch(API+'/retries/'+id+'/retry',{method:'POST'});
|
||||
if(r&&r.ok)toast('success','重试任务已触发');else toast('error','触发重试失败');
|
||||
loadRetries();
|
||||
}
|
||||
|
||||
async function deleteJob(id){
|
||||
if(!confirm('确定删除此重试任务?'))return;
|
||||
const r=await apiFetch(API+'/retries/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','重试任务已删除');else toast('error','删除失败');
|
||||
loadRetries();
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
loadRetries();
|
||||
</script>
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
loadRetries();
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+263
-263
@@ -2,287 +2,287 @@
|
||||
<script src="/app/vendor/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
|
||||
<button onclick="showAddSched()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="schedList" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-8">加载中...</div></div>
|
||||
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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>
|
||||
<button onclick="showAddSched()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="schedList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div id="schedModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 py-6 overflow-y-auto">
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 my-auto">
|
||||
<h2 id="schedModalTitle" class="text-white font-semibold text-lg">新建调度</h2>
|
||||
<input type="hidden" id="editSchedId">
|
||||
<!-- Modal -->
|
||||
<div id="schedModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 py-6 overflow-y-auto">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-4 my-auto">
|
||||
<h2 id="schedModalTitle" class="text-white font-semibold text-lg">新建调度</h2>
|
||||
<input type="hidden" id="editSchedId">
|
||||
|
||||
<!-- Basic -->
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">名称 *</label><input id="sName" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="每日更新"></div>
|
||||
<!-- Basic -->
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称 *</label><input id="sName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="每日更新"></div>
|
||||
|
||||
<!-- Run mode selector -->
|
||||
<div>
|
||||
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">执行方式 *</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sRunMode" value="once" checked class="accent-brand" onchange="onRunModeChange()">
|
||||
<span class="text-sm">🕐 一次性</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sRunMode" value="cron" class="accent-brand" onchange="onRunModeChange()">
|
||||
<span class="text-sm">🔁 循环 (Cron)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Once: fire_at -->
|
||||
<div id="onceFields">
|
||||
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">执行时间 *</label>
|
||||
<input id="sFireAt" type="datetime-local" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Cron: expression -->
|
||||
<div id="cronFields" class="hidden">
|
||||
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">Cron 表达式 *</label>
|
||||
<input id="sCron" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm font-mono" placeholder="0 2 * * *">
|
||||
<div class="text-gray-300 dark:text-gray-600 text-xs mt-1">分 时 日 月 周 · 0 2 * * * = 每天凌晨 2 点 · 最小精度 1 分钟</div>
|
||||
</div>
|
||||
|
||||
<!-- Type selector -->
|
||||
<div>
|
||||
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">调度类型 *</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sType" value="push" checked class="accent-brand" onchange="onTypeChange()">
|
||||
<span class="text-sm">📁 文件推送 (rsync)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sType" value="script" class="accent-brand" onchange="onTypeChange()">
|
||||
<span class="text-sm">⚡ 脚本执行</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Push fields -->
|
||||
<div id="pushFields" class="space-y-3">
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">源路径 *</label><input id="sPath" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm" placeholder="/home/deploy/project/"></div>
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">同步模式</label><select id="sSyncMode" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步(--delete)</option><option value="checksum">校验和</option></select></div>
|
||||
</div>
|
||||
|
||||
<!-- Script fields -->
|
||||
<div id="scriptFields" class="hidden space-y-3">
|
||||
<div>
|
||||
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">脚本库选择(可选)</label>
|
||||
<select id="sScriptId" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
|
||||
<option value="">不使用脚本库(使用下方命令)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">命令内容(脚本库为空时生效)</label>
|
||||
<textarea id="sScriptContent" rows="4" placeholder="echo 'scheduled task' uptime" class="w-full px-3 py-2 bg-gray-50 dark:bg-gray-950 border border-gray-300 dark:border-gray-700 rounded-lg text-green-400 text-sm font-mono resize-y"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">超时(秒)</label><input id="sTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm"></div>
|
||||
<div class="flex items-center pt-5"><label class="flex items-center gap-2 cursor-pointer"><input type="checkbox" id="sLongTask" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"><span class="text-sm text-slate-300">长任务(nohup)</span></label></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common: server selector -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-gray-500 dark:text-gray-400 text-xs">目标服务器 *</label>
|
||||
<button onclick="Array.from(document.getElementById('sServers').options).forEach(o=>o.selected=true)" class="text-brand-light text-xs hover:underline">全选</button>
|
||||
</div>
|
||||
<select id="sServers" multiple class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm h-32"></select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button onclick="saveSched()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm font-medium">保存</button>
|
||||
<button onclick="hideSchedModal()" class="px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Run mode selector -->
|
||||
<div>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">执行方式 *</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sRunMode" value="once" checked class="accent-brand" onchange="onRunModeChange()">
|
||||
<span class="text-sm">🕐 一次性</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sRunMode" value="cron" class="accent-brand" onchange="onRunModeChange()">
|
||||
<span class="text-sm">🔁 循环 (Cron)</span>
|
||||
</label>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('schedules');
|
||||
let _schedsCache={};
|
||||
let _scriptLibCache=[];
|
||||
</div>
|
||||
|
||||
function onTypeChange(){
|
||||
const t=document.querySelector('input[name="sType"]:checked')?.value||'push';
|
||||
document.getElementById('pushFields').classList.toggle('hidden',t!=='push');
|
||||
document.getElementById('scriptFields').classList.toggle('hidden',t!=='script');
|
||||
}
|
||||
function onRunModeChange(){
|
||||
const m=document.querySelector('input[name="sRunMode"]:checked')?.value||'once';
|
||||
document.getElementById('onceFields').classList.toggle('hidden',m!=='once');
|
||||
document.getElementById('cronFields').classList.toggle('hidden',m!=='cron');
|
||||
}
|
||||
<!-- Once: fire_at -->
|
||||
<div id="onceFields">
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">执行时间 *</label>
|
||||
<input id="sFireAt" type="datetime-local" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
</div>
|
||||
|
||||
async function loadScheds(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/schedules/');if(!r)return;const scheds=await r.json();
|
||||
_schedsCache={};scheds.forEach(s=>_schedsCache[s.id]=s);
|
||||
const TYPE_LABEL={push:'📁 推送',script:'⚡ 脚本'};
|
||||
document.getElementById('schedList').innerHTML=scheds.length?scheds.map(s=>{
|
||||
const isScript=(s.schedule_type||'push')==='script';
|
||||
const detail=isScript
|
||||
?`${esc(s.script_content&&s.script_content.substring(0,60)||'(脚本库 #'+(s.script_id||'?')+')').replace(/\n/g,' ')}...`
|
||||
:`${esc(s.source_path||'')} · ${esc(s.sync_mode||'incremental')}`;
|
||||
const runModeLabel=(s.run_mode||'once')==='cron'?'🔁 循环':'🕐 一次';
|
||||
const scheduleDesc=(s.run_mode||'once')==='cron'
|
||||
?`<span class="font-mono">${esc(s.cron_expr)}</span>`
|
||||
:`一次性 ${s.fire_at?fmtTime(s.fire_at):'(未设置)'}`;
|
||||
const doneBadge=(s.run_mode==='once'&&s.last_run_at)?' <span class="text-gray-300 dark:text-gray-600 text-[10px]">✓ 已执行</span>':'';
|
||||
return `<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button onclick="toggleSched(${s.id},${!s.enabled})" class="w-10 h-6 rounded-full transition-colors ${s.enabled?'bg-brand':'bg-slate-700'} relative shrink-0" title="${s.enabled?'点击禁用':'点击启用'}">
|
||||
<span class="absolute top-0.5 ${s.enabled?'right-0.5':'left-0.5'} w-5 h-5 bg-white rounded-full shadow transition-all"></span>
|
||||
</button>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white font-medium">${esc(s.name)}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">${TYPE_LABEL[s.schedule_type||'push']||'推送'}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500">${runModeLabel}</span>
|
||||
</div>
|
||||
<div class="text-gray-400 dark:text-gray-500 text-xs mt-0.5 truncate">
|
||||
${scheduleDesc} · ${detail}${s.last_run_at&&s.run_mode==='cron'?' · 上次: '+fmtTime(s.last_run_at):''}${doneBadge}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 ml-3">
|
||||
<button onclick="showEditSched(${s.id})" class="text-gray-500 dark:text-gray-400 hover:text-white text-xs transition">编辑</button>
|
||||
<button onclick="deleteSched(${s.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`}).join(''):'<div class="text-gray-400 dark:text-gray-500 text-center py-8">暂无调度</div>';
|
||||
}catch(e){document.getElementById('schedList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
<!-- Cron: expression -->
|
||||
<div id="cronFields" class="hidden">
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">Cron 表达式 *</label>
|
||||
<input id="sCron" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="0 2 * * *">
|
||||
<div class="text-[var(--text-dim)] text-xs mt-1">分 时 日 月 周 · 0 2 * * * = 每天凌晨 2 点 · 最小精度 1 分钟</div>
|
||||
</div>
|
||||
|
||||
async function loadServersIntoSelect(selectedIds){
|
||||
const r=await apiFetch(API+'/servers/?per_page=200');if(!r)return;const data=await r.json();const servers=data.items||data;
|
||||
document.getElementById('sServers').innerHTML=servers.map(s=>`<option value="${s.id}"${selectedIds&&selectedIds.includes(s.id)?' selected':''}>${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
}
|
||||
<!-- Type selector -->
|
||||
<div>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">调度类型 *</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sType" value="push" checked class="accent-brand" onchange="onTypeChange()">
|
||||
<span class="text-sm">📁 文件推送 (rsync)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sType" value="script" class="accent-brand" onchange="onTypeChange()">
|
||||
<span class="text-sm">⚡ 脚本执行</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
async function loadScriptsIntoSelect(selectedId){
|
||||
if(!_scriptLibCache.length){
|
||||
const r=await apiFetch(API+'/scripts/');if(!r)return;
|
||||
const scripts=await r.json();_scriptLibCache=scripts;
|
||||
}
|
||||
const sel=document.getElementById('sScriptId');
|
||||
sel.innerHTML='<option value="">不使用脚本库(使用下方命令)</option>'+
|
||||
_scriptLibCache.map(s=>`<option value="${s.id}"${s.id==selectedId?' selected':''}>${esc(s.name)}</option>`).join('');
|
||||
}
|
||||
<!-- Push fields -->
|
||||
<div id="pushFields" class="space-y-3">
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">源路径 *</label><input id="sPath" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="/home/deploy/project/"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">同步模式</label><select id="sSyncMode" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步(--delete)</option><option value="checksum">校验和</option></select></div>
|
||||
</div>
|
||||
|
||||
function showAddSched(){
|
||||
document.getElementById('editSchedId').value='';
|
||||
document.getElementById('schedModalTitle').textContent='新建调度';
|
||||
document.getElementById('sName').value='';document.getElementById('sPath').value='';
|
||||
document.getElementById('sCron').value='';document.getElementById('sFireAt').value='';
|
||||
document.getElementById('sSyncMode').value='incremental';
|
||||
document.getElementById('sScriptContent').value='';document.getElementById('sTimeout').value='60';
|
||||
document.getElementById('sLongTask').checked=false;
|
||||
document.querySelector('input[name="sType"][value="push"]').checked=true;
|
||||
document.querySelector('input[name="sRunMode"][value="once"]').checked=true;
|
||||
onTypeChange();onRunModeChange();
|
||||
Promise.all([loadServersIntoSelect([]),loadScriptsIntoSelect(null)]);
|
||||
document.getElementById('schedModal').classList.remove('hidden');
|
||||
}
|
||||
<!-- Script fields -->
|
||||
<div id="scriptFields" class="hidden space-y-3">
|
||||
<div>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">脚本库选择(可选)</label>
|
||||
<select id="sScriptId" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">不使用脚本库(使用下方命令)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">命令内容(脚本库为空时生效)</label>
|
||||
<textarea id="sScriptContent" rows="4" placeholder="echo 'scheduled task' uptime" class="w-full px-3 py-2 bg-[var(--bg-page)] border border-[var(--border-input)] rounded-lg text-green-400 text-sm font-mono resize-y"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">超时(秒)</label><input id="sTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"></div>
|
||||
<div class="flex items-center pt-5"><label class="flex items-center gap-2 cursor-pointer"><input type="checkbox" id="sLongTask" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"><span class="text-sm text-slate-300">长任务(nohup)</span></label></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
async function showEditSched(id){
|
||||
const s=_schedsCache[id];if(!s)return;
|
||||
document.getElementById('editSchedId').value=s.id;
|
||||
document.getElementById('schedModalTitle').textContent='编辑调度';
|
||||
document.getElementById('sName').value=s.name||'';
|
||||
document.getElementById('sPath').value=s.source_path||'';
|
||||
document.getElementById('sCron').value=s.cron_expr||'';
|
||||
if(s.fire_at){
|
||||
const d=new Date(s.fire_at+'Z');
|
||||
const local=new Date(d.getTime()-d.getTimezoneOffset()*60000);
|
||||
document.getElementById('sFireAt').value=local.toISOString().slice(0,16);
|
||||
}else{document.getElementById('sFireAt').value=''}
|
||||
document.getElementById('sSyncMode').value=s.sync_mode||'incremental';
|
||||
document.getElementById('sScriptContent').value=s.script_content||'';
|
||||
document.getElementById('sTimeout').value=s.exec_timeout||60;
|
||||
document.getElementById('sLongTask').checked=!!s.long_task;
|
||||
const stype=(s.schedule_type||'push');
|
||||
document.querySelector(`input[name="sType"][value="${stype}"]`).checked=true;
|
||||
const rmode=(s.run_mode||'once');
|
||||
document.querySelector(`input[name="sRunMode"][value="${rmode}"]`).checked=true;
|
||||
onTypeChange();onRunModeChange();
|
||||
let serverIds=[];try{serverIds=JSON.parse(s.server_ids||'[]')}catch(e){}
|
||||
await Promise.all([loadServersIntoSelect(serverIds),loadScriptsIntoSelect(s.script_id)]);
|
||||
document.getElementById('schedModal').classList.remove('hidden');
|
||||
}
|
||||
<!-- Common: server selector -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-[var(--text-secondary)] text-xs">目标服务器 *</label>
|
||||
<button onclick="Array.from(document.getElementById('sServers').options).forEach(o=>o.selected=true)" class="text-brand-light text-xs hover:underline">全选</button>
|
||||
</div>
|
||||
<select id="sServers" multiple class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm h-32"></select>
|
||||
</div>
|
||||
|
||||
function hideSchedModal(){document.getElementById('schedModal').classList.add('hidden')}
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button onclick="saveSched()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm font-medium">保存</button>
|
||||
<button onclick="hideSchedModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('schedules');
|
||||
let _schedsCache={};
|
||||
let _scriptLibCache=[];
|
||||
|
||||
async function saveSched(){
|
||||
const id=document.getElementById('editSchedId').value;
|
||||
const opts=document.getElementById('sServers').selectedOptions;
|
||||
const serverIds=Array.from(opts).map(o=>parseInt(o.value));
|
||||
if(serverIds.length===0){toast('warning','请选择目标服务器');return}
|
||||
const name=document.getElementById('sName').value.trim();
|
||||
if(!name){toast('warning','名称为必填项');return}
|
||||
const runMode=document.querySelector('input[name="sRunMode"]:checked')?.value||'once';
|
||||
const stype=document.querySelector('input[name="sType"]:checked')?.value||'push';
|
||||
function onTypeChange(){
|
||||
const t=document.querySelector('input[name="sType"]:checked')?.value||'push';
|
||||
document.getElementById('pushFields').classList.toggle('hidden',t!=='push');
|
||||
document.getElementById('scriptFields').classList.toggle('hidden',t!=='script');
|
||||
}
|
||||
function onRunModeChange(){
|
||||
const m=document.querySelector('input[name="sRunMode"]:checked')?.value||'once';
|
||||
document.getElementById('onceFields').classList.toggle('hidden',m!=='once');
|
||||
document.getElementById('cronFields').classList.toggle('hidden',m!=='cron');
|
||||
}
|
||||
|
||||
const body={name,run_mode:runMode,server_ids:JSON.stringify(serverIds),schedule_type:stype,enabled:true};
|
||||
async function loadScheds(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/schedules/');if(!r)return;const scheds=await r.json();
|
||||
_schedsCache={};scheds.forEach(s=>_schedsCache[s.id]=s);
|
||||
const TYPE_LABEL={push:'📁 推送',script:'⚡ 脚本'};
|
||||
document.getElementById('schedList').innerHTML=scheds.length?scheds.map(s=>{
|
||||
const isScript=(s.schedule_type||'push')==='script';
|
||||
const detail=isScript
|
||||
?`${esc(s.script_content&&s.script_content.substring(0,60)||'(脚本库 #'+(s.script_id||'?')+')').replace(/\n/g,' ')}...`
|
||||
:`${esc(s.source_path||'')} · ${esc(s.sync_mode||'incremental')}`;
|
||||
const runModeLabel=(s.run_mode||'once')==='cron'?'🔁 循环':'🕐 一次';
|
||||
const scheduleDesc=(s.run_mode||'once')==='cron'
|
||||
?`<span class="font-mono">${esc(s.cron_expr)}</span>`
|
||||
:`一次性 ${s.fire_at?fmtTime(s.fire_at):'(未设置)'}`;
|
||||
const doneBadge=(s.run_mode==='once'&&s.last_run_at)?' <span class="text-[var(--text-dim)] text-[10px]">✓ 已执行</span>':'';
|
||||
return `<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button onclick="toggleSched(${s.id},${!s.enabled})" class="w-10 h-6 rounded-full transition-colors ${s.enabled?'bg-brand':'bg-slate-700'} relative shrink-0" title="${s.enabled?'点击禁用':'点击启用'}">
|
||||
<span class="absolute top-0.5 ${s.enabled?'right-0.5':'left-0.5'} w-5 h-5 bg-white rounded-full shadow transition-all"></span>
|
||||
</button>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white font-medium">${esc(s.name)}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-secondary)]">${TYPE_LABEL[s.schedule_type||'push']||'推送'}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-muted)]">${runModeLabel}</span>
|
||||
</div>
|
||||
<div class="text-[var(--text-muted)] text-xs mt-0.5 truncate">
|
||||
${scheduleDesc} · ${detail}${s.last_run_at&&s.run_mode==='cron'?' · 上次: '+fmtTime(s.last_run_at):''}${doneBadge}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 ml-3">
|
||||
<button onclick="showEditSched(${s.id})" class="text-[var(--text-secondary)] hover:text-white text-xs transition">编辑</button>
|
||||
<button onclick="deleteSched(${s.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`}).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无调度</div>';
|
||||
}catch(e){document.getElementById('schedList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
if(runMode==='once'){
|
||||
const fireAt=document.getElementById('sFireAt').value;
|
||||
if(!fireAt){toast('warning','一次性调度需要选择执行时间');return}
|
||||
body.fire_at=new Date(fireAt).toISOString();
|
||||
}else{
|
||||
const cronExpr=document.getElementById('sCron').value.trim();
|
||||
if(!cronExpr){toast('warning','循环调度需要填写 Cron 表达式');return}
|
||||
body.cron_expr=cronExpr;
|
||||
}
|
||||
async function loadServersIntoSelect(selectedIds){
|
||||
const r=await apiFetch(API+'/servers/?per_page=200');if(!r)return;const data=await r.json();const servers=data.items||data;
|
||||
document.getElementById('sServers').innerHTML=servers.map(s=>`<option value="${s.id}"${selectedIds&&selectedIds.includes(s.id)?' selected':''}>${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
}
|
||||
|
||||
if(stype==='push'){
|
||||
body.source_path=document.getElementById('sPath').value.trim();
|
||||
body.sync_mode=document.getElementById('sSyncMode').value||'incremental';
|
||||
if(!body.source_path){toast('warning','推送调度需要填写源路径');return}
|
||||
}else{
|
||||
const scriptId=document.getElementById('sScriptId').value;
|
||||
const content=document.getElementById('sScriptContent').value.trim();
|
||||
if(!scriptId&&!content){toast('warning','脚本调度需要选择脚本库或填写命令内容');return}
|
||||
if(scriptId)body.script_id=parseInt(scriptId);
|
||||
if(content)body.script_content=content;
|
||||
body.exec_timeout=parseInt(document.getElementById('sTimeout').value)||60;
|
||||
body.long_task=document.getElementById('sLongTask').checked;
|
||||
}
|
||||
async function loadScriptsIntoSelect(selectedId){
|
||||
if(!_scriptLibCache.length){
|
||||
const r=await apiFetch(API+'/scripts/');if(!r)return;
|
||||
const scripts=await r.json();_scriptLibCache=scripts;
|
||||
}
|
||||
const sel=document.getElementById('sScriptId');
|
||||
sel.innerHTML='<option value="">不使用脚本库(使用下方命令)</option>'+
|
||||
_scriptLibCache.map(s=>`<option value="${s.id}"${s.id==selectedId?' selected':''}>${esc(s.name)}</option>`).join('');
|
||||
}
|
||||
|
||||
const method=id?'PUT':'POST';
|
||||
const url=id?API+'/schedules/'+id:API+'/schedules/';
|
||||
const r=await apiFetch(url,{method,headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok){toast('success',id?'调度已更新':'调度已创建');hideSchedModal();loadScheds();}
|
||||
else{const e=await r?.json().catch(()=>({detail:'保存失败'}));toast('error',e.detail||'保存失败')}
|
||||
}
|
||||
function showAddSched(){
|
||||
document.getElementById('editSchedId').value='';
|
||||
document.getElementById('schedModalTitle').textContent='新建调度';
|
||||
document.getElementById('sName').value='';document.getElementById('sPath').value='';
|
||||
document.getElementById('sCron').value='';document.getElementById('sFireAt').value='';
|
||||
document.getElementById('sSyncMode').value='incremental';
|
||||
document.getElementById('sScriptContent').value='';document.getElementById('sTimeout').value='60';
|
||||
document.getElementById('sLongTask').checked=false;
|
||||
document.querySelector('input[name="sType"][value="push"]').checked=true;
|
||||
document.querySelector('input[name="sRunMode"][value="once"]').checked=true;
|
||||
onTypeChange();onRunModeChange();
|
||||
Promise.all([loadServersIntoSelect([]),loadScriptsIntoSelect(null)]);
|
||||
document.getElementById('schedModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function toggleSched(id,enabled){
|
||||
const r=await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({enabled})});
|
||||
if(r&&r.ok)toast('info',enabled?'已启用':'已禁用');loadScheds();
|
||||
}
|
||||
async function showEditSched(id){
|
||||
const s=_schedsCache[id];if(!s)return;
|
||||
document.getElementById('editSchedId').value=s.id;
|
||||
document.getElementById('schedModalTitle').textContent='编辑调度';
|
||||
document.getElementById('sName').value=s.name||'';
|
||||
document.getElementById('sPath').value=s.source_path||'';
|
||||
document.getElementById('sCron').value=s.cron_expr||'';
|
||||
if(s.fire_at){
|
||||
const d=new Date(s.fire_at+'Z');
|
||||
const local=new Date(d.getTime()-d.getTimezoneOffset()*60000);
|
||||
document.getElementById('sFireAt').value=local.toISOString().slice(0,16);
|
||||
}else{document.getElementById('sFireAt').value=''}
|
||||
document.getElementById('sSyncMode').value=s.sync_mode||'incremental';
|
||||
document.getElementById('sScriptContent').value=s.script_content||'';
|
||||
document.getElementById('sTimeout').value=s.exec_timeout||60;
|
||||
document.getElementById('sLongTask').checked=!!s.long_task;
|
||||
const stype=(s.schedule_type||'push');
|
||||
document.querySelector(`input[name="sType"][value="${stype}"]`).checked=true;
|
||||
const rmode=(s.run_mode||'once');
|
||||
document.querySelector(`input[name="sRunMode"][value="${rmode}"]`).checked=true;
|
||||
onTypeChange();onRunModeChange();
|
||||
let serverIds=[];try{serverIds=JSON.parse(s.server_ids||'[]')}catch(e){}
|
||||
await Promise.all([loadServersIntoSelect(serverIds),loadScriptsIntoSelect(s.script_id)]);
|
||||
document.getElementById('schedModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function deleteSched(id){
|
||||
if(!confirm('确定删除此调度?'))return;
|
||||
const r=await apiFetch(API+'/schedules/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','已删除');else toast('error','删除失败');loadScheds();
|
||||
}
|
||||
function hideSchedModal(){document.getElementById('schedModal').classList.add('hidden')}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
async function saveSched(){
|
||||
const id=document.getElementById('editSchedId').value;
|
||||
const opts=document.getElementById('sServers').selectedOptions;
|
||||
const serverIds=Array.from(opts).map(o=>parseInt(o.value));
|
||||
if(serverIds.length===0){toast('warning','请选择目标服务器');return}
|
||||
const name=document.getElementById('sName').value.trim();
|
||||
if(!name){toast('warning','名称为必填项');return}
|
||||
const runMode=document.querySelector('input[name="sRunMode"]:checked')?.value||'once';
|
||||
const stype=document.querySelector('input[name="sType"]:checked')?.value||'push';
|
||||
|
||||
loadScheds().then(()=>{
|
||||
const hid=new URLSearchParams(location.search).get('highlight');
|
||||
if(hid)showEditSched(parseInt(hid));
|
||||
});
|
||||
</script>
|
||||
const body={name,run_mode:runMode,server_ids:JSON.stringify(serverIds),schedule_type:stype,enabled:true};
|
||||
|
||||
if(runMode==='once'){
|
||||
const fireAt=document.getElementById('sFireAt').value;
|
||||
if(!fireAt){toast('warning','一次性调度需要选择执行时间');return}
|
||||
body.fire_at=new Date(fireAt).toISOString();
|
||||
}else{
|
||||
const cronExpr=document.getElementById('sCron').value.trim();
|
||||
if(!cronExpr){toast('warning','循环调度需要填写 Cron 表达式');return}
|
||||
body.cron_expr=cronExpr;
|
||||
}
|
||||
|
||||
if(stype==='push'){
|
||||
body.source_path=document.getElementById('sPath').value.trim();
|
||||
body.sync_mode=document.getElementById('sSyncMode').value||'incremental';
|
||||
if(!body.source_path){toast('warning','推送调度需要填写源路径');return}
|
||||
}else{
|
||||
const scriptId=document.getElementById('sScriptId').value;
|
||||
const content=document.getElementById('sScriptContent').value.trim();
|
||||
if(!scriptId&&!content){toast('warning','脚本调度需要选择脚本库或填写命令内容');return}
|
||||
if(scriptId)body.script_id=parseInt(scriptId);
|
||||
if(content)body.script_content=content;
|
||||
body.exec_timeout=parseInt(document.getElementById('sTimeout').value)||60;
|
||||
body.long_task=document.getElementById('sLongTask').checked;
|
||||
}
|
||||
|
||||
const method=id?'PUT':'POST';
|
||||
const url=id?API+'/schedules/'+id:API+'/schedules/';
|
||||
const r=await apiFetch(url,{method,headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(r&&r.ok){toast('success',id?'调度已更新':'调度已创建');hideSchedModal();loadScheds();}
|
||||
else{const e=await r?.json().catch(()=>({detail:'保存失败'}));toast('error',e.detail||'保存失败')}
|
||||
}
|
||||
|
||||
async function toggleSched(id,enabled){
|
||||
const r=await apiFetch(API+'/schedules/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({enabled})});
|
||||
if(r&&r.ok)toast('info',enabled?'已启用':'已禁用');loadScheds();
|
||||
}
|
||||
|
||||
async function deleteSched(id){
|
||||
if(!confirm('确定删除此调度?'))return;
|
||||
const r=await apiFetch(API+'/schedules/'+id,{method:'DELETE'});
|
||||
if(r&&r.ok)toast('success','已删除');else toast('error','删除失败');loadScheds();
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
|
||||
loadScheds().then(()=>{
|
||||
const hid=new URLSearchParams(location.search).get('highlight');
|
||||
if(hid)showEditSched(parseInt(hid));
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+177
-177
@@ -1,190 +1,190 @@
|
||||
<!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}">
|
||||
<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"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
|
||||
<h1 class="text-white font-semibold">脚本执行历史</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/app/scripts.html" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg">脚本库</a>
|
||||
<select id="statusFilter" onchange="_offset=0;loadList()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="running">执行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="partial">部分失败</option>
|
||||
</select>
|
||||
<button onclick="_offset=0;loadList()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">ID</th>
|
||||
<th class="text-left px-4 py-3">状态</th>
|
||||
<th class="text-left px-4 py-3">进度</th>
|
||||
<th class="text-left px-4 py-3">操作人</th>
|
||||
<th class="text-left px-4 py-3">命令</th>
|
||||
<th class="text-left px-4 py-3">开始</th>
|
||||
<th class="text-right px-4 py-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="listBody" class="divide-y divide-slate-800"><tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pagination" class="hidden mt-4 flex items-center justify-between">
|
||||
<span id="pageInfo" class="text-slate-500 text-sm"></span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-slate-800 text-white text-sm rounded-lg disabled:opacity-40">上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-slate-800 text-white text-sm rounded-lg disabled:opacity-40">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="detailPanel" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex justify-between items-center mb-2 flex-wrap gap-2">
|
||||
<h2 class="text-white font-medium" id="detailTitle">执行详情</h2>
|
||||
<div id="detailActions" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
<pre id="detailPre" class="text-xs text-slate-300 font-mono bg-slate-950 p-3 rounded-lg max-h-96 overflow-auto whitespace-pre-wrap"></pre>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('script_exec');
|
||||
const PAGE=30;
|
||||
let _offset=0,_total=0,_pollTimer=null,_detailId=null,_detailData=null;
|
||||
<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"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
|
||||
<h1 class="text-white font-semibold">脚本执行历史</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/app/scripts.html" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg">脚本库</a>
|
||||
<select id="statusFilter" onchange="_offset=0;loadList()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="running">执行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="partial">部分失败</option>
|
||||
</select>
|
||||
<button onclick="_offset=0;loadList()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">ID</th>
|
||||
<th class="text-left px-4 py-3">状态</th>
|
||||
<th class="text-left px-4 py-3">进度</th>
|
||||
<th class="text-left px-4 py-3">操作人</th>
|
||||
<th class="text-left px-4 py-3">命令</th>
|
||||
<th class="text-left px-4 py-3">开始</th>
|
||||
<th class="text-right px-4 py-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="listBody" class="divide-y divide-slate-800"><tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pagination" class="hidden mt-4 flex items-center justify-between">
|
||||
<span id="pageInfo" class="text-slate-500 text-sm"></span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-slate-800 text-white text-sm rounded-lg disabled:opacity-40">上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-slate-800 text-white text-sm rounded-lg disabled:opacity-40">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="detailPanel" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex justify-between items-center mb-2 flex-wrap gap-2">
|
||||
<h2 class="text-white font-medium" id="detailTitle">执行详情</h2>
|
||||
<div id="detailActions" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
<pre id="detailPre" class="text-xs text-slate-300 font-mono bg-slate-950 p-3 rounded-lg max-h-96 overflow-auto whitespace-pre-wrap"></pre>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('script_exec');
|
||||
const PAGE=30;
|
||||
let _offset=0,_total=0,_pollTimer=null,_detailId=null,_detailData=null;
|
||||
|
||||
const ST={running:'执行中',completed:'完成',failed:'失败',partial:'部分失败'};
|
||||
const CLS={running:'text-amber-300',completed:'text-emerald-300',failed:'text-red-300',partial:'text-orange-300'};
|
||||
const ST={running:'执行中',completed:'完成',failed:'失败',partial:'部分失败'};
|
||||
const CLS={running:'text-amber-300',completed:'text-emerald-300',failed:'text-red-300',partial:'text-orange-300'};
|
||||
|
||||
function fmt(t){if(!t)return'--';const s=String(t);const iso=s.endsWith('Z')||s.includes('+')?s:s+'Z';return new Date(iso).toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
function fmt(t){if(!t)return'--';const s=String(t);const iso=s.endsWith('Z')||s.includes('+')?s:s+'Z';return new Date(iso).toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
|
||||
function hasPending(results){
|
||||
if(!results||typeof results!=='object')return false;
|
||||
return Object.entries(results).some(([k,v])=>k!=='_meta'&&v&&v.phase==='pending');
|
||||
}
|
||||
function canRetry(status,results){
|
||||
return status==='failed'||status==='partial'||(status==='running'&&hasPending(results));
|
||||
}
|
||||
function hasPending(results){
|
||||
if(!results||typeof results!=='object')return false;
|
||||
return Object.entries(results).some(([k,v])=>k!=='_meta'&&v&&v.phase==='pending');
|
||||
}
|
||||
function canRetry(status,results){
|
||||
return status==='failed'||status==='partial'||(status==='running'&&hasPending(results));
|
||||
}
|
||||
|
||||
async function loadList(){
|
||||
const st=document.getElementById('statusFilter').value;
|
||||
const url=API+'/scripts/executions?limit='+PAGE+'&offset='+_offset+(st?'&status='+encodeURIComponent(st):'');
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const data=await r.json();
|
||||
_total=data.total||0;
|
||||
const items=data.items||[];
|
||||
const tb=document.getElementById('listBody');
|
||||
if(!items.length){tb.innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">暂无记录</td></tr>';updatePage();setupPoll();return}
|
||||
tb.innerHTML=items.map(ex=>{
|
||||
const cmd=(ex.command||'').replace(/^\[long_task\] /,'');
|
||||
const src=ex.source==='redis'?' <span class="text-slate-600">(热)</span>':'';
|
||||
const prog=ex.progress||'—';
|
||||
const run=ex.status==='running'||hasPending(ex.results);
|
||||
const stopBtn=run?`<button onclick="stopExec(${ex.id})" class="text-red-300 text-xs hover:underline mr-2">停止</button>`:'';
|
||||
const retryBtn=canRetry(ex.status,ex.results)?`<button onclick="retryExec(${ex.id},${!!ex.long_task})" class="text-amber-200 text-xs hover:underline mr-2">重试</button>`:'';
|
||||
const stuckBtn=run?`<button onclick="markStuck(${ex.id})" class="text-slate-400 text-xs hover:underline mr-2">标记卡住</button>`:'';
|
||||
return `<tr class="hover:bg-slate-800/30">
|
||||
<td class="px-4 py-3 text-white">#${ex.id}${src}</td>
|
||||
<td class="px-4 py-3 ${CLS[ex.status]||''}">${ST[ex.status]||esc(ex.status)}</td>
|
||||
<td class="px-4 py-3 text-slate-400 text-xs">${esc(prog)}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${esc(ex.operator||'--')}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs max-w-xs truncate" title="${esc(cmd)}">${esc(cmd)}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs whitespace-nowrap">${fmt(ex.started_at)}</td>
|
||||
<td class="px-4 py-3 text-right text-xs whitespace-nowrap">
|
||||
${stopBtn}${retryBtn}${stuckBtn}
|
||||
<button onclick="showDetail(${ex.id},false)" class="text-brand-light hover:underline mr-2">详情</button>
|
||||
<button onclick="showDetail(${ex.id},true)" class="text-slate-400 hover:underline">+日志</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
updatePage();
|
||||
setupPoll();
|
||||
}
|
||||
async function loadList(){
|
||||
const st=document.getElementById('statusFilter').value;
|
||||
const url=API+'/scripts/executions?limit='+PAGE+'&offset='+_offset+(st?'&status='+encodeURIComponent(st):'');
|
||||
const r=await apiFetch(url);if(!r)return;
|
||||
const data=await r.json();
|
||||
_total=data.total||0;
|
||||
const items=data.items||[];
|
||||
const tb=document.getElementById('listBody');
|
||||
if(!items.length){tb.innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">暂无记录</td></tr>';updatePage();setupPoll();return}
|
||||
tb.innerHTML=items.map(ex=>{
|
||||
const cmd=(ex.command||'').replace(/^\[long_task\] /,'');
|
||||
const src=ex.source==='redis'?' <span class="text-slate-600">(热)</span>':'';
|
||||
const prog=ex.progress||'—';
|
||||
const run=ex.status==='running'||hasPending(ex.results);
|
||||
const stopBtn=run?`<button onclick="stopExec(${ex.id})" class="text-red-300 text-xs hover:underline mr-2">停止</button>`:'';
|
||||
const retryBtn=canRetry(ex.status,ex.results)?`<button onclick="retryExec(${ex.id},${!!ex.long_task})" class="text-amber-200 text-xs hover:underline mr-2">重试</button>`:'';
|
||||
const stuckBtn=run?`<button onclick="markStuck(${ex.id})" class="text-slate-400 text-xs hover:underline mr-2">标记卡住</button>`:'';
|
||||
return `<tr class="hover:bg-slate-800/30">
|
||||
<td class="px-4 py-3 text-white">#${ex.id}${src}</td>
|
||||
<td class="px-4 py-3 ${CLS[ex.status]||''}">${ST[ex.status]||esc(ex.status)}</td>
|
||||
<td class="px-4 py-3 text-slate-400 text-xs">${esc(prog)}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${esc(ex.operator||'--')}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs max-w-xs truncate" title="${esc(cmd)}">${esc(cmd)}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs whitespace-nowrap">${fmt(ex.started_at)}</td>
|
||||
<td class="px-4 py-3 text-right text-xs whitespace-nowrap">
|
||||
${stopBtn}${retryBtn}${stuckBtn}
|
||||
<button onclick="showDetail(${ex.id},false)" class="text-brand-light hover:underline mr-2">详情</button>
|
||||
<button onclick="showDetail(${ex.id},true)" class="text-slate-400 hover:underline">+日志</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
updatePage();
|
||||
setupPoll();
|
||||
}
|
||||
|
||||
function setupPoll(){
|
||||
if(_pollTimer){clearInterval(_pollTimer);_pollTimer=null}
|
||||
const st=document.getElementById('statusFilter').value;
|
||||
if(st==='running'||st===''){
|
||||
_pollTimer=setInterval(()=>{loadList();if(_detailId)showDetail(_detailId,false,true)},10000);
|
||||
}
|
||||
}
|
||||
function setupPoll(){
|
||||
if(_pollTimer){clearInterval(_pollTimer);_pollTimer=null}
|
||||
const st=document.getElementById('statusFilter').value;
|
||||
if(st==='running'||st===''){
|
||||
_pollTimer=setInterval(()=>{loadList();if(_detailId)showDetail(_detailId,false,true)},10000);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePage(){
|
||||
const page=Math.floor(_offset/PAGE)+1,totalPages=Math.max(1,Math.ceil(_total/PAGE));
|
||||
document.getElementById('pagination').classList.toggle('hidden',_total<=PAGE);
|
||||
document.getElementById('pageInfo').textContent=`第 ${page}/${totalPages} 页 · 共 ${_total} 条`;
|
||||
document.getElementById('prevBtn').disabled=_offset<=0;
|
||||
document.getElementById('nextBtn').disabled=_offset+PAGE>=_total;
|
||||
}
|
||||
function prevPage(){if(_offset>0){_offset-=PAGE;loadList()}}
|
||||
function nextPage(){if(_offset+PAGE<_total){_offset+=PAGE;loadList()}}
|
||||
function updatePage(){
|
||||
const page=Math.floor(_offset/PAGE)+1,totalPages=Math.max(1,Math.ceil(_total/PAGE));
|
||||
document.getElementById('pagination').classList.toggle('hidden',_total<=PAGE);
|
||||
document.getElementById('pageInfo').textContent=`第 ${page}/${totalPages} 页 · 共 ${_total} 条`;
|
||||
document.getElementById('prevBtn').disabled=_offset<=0;
|
||||
document.getElementById('nextBtn').disabled=_offset+PAGE>=_total;
|
||||
}
|
||||
function prevPage(){if(_offset>0){_offset-=PAGE;loadList()}}
|
||||
function nextPage(){if(_offset+PAGE<_total){_offset+=PAGE;loadList()}}
|
||||
|
||||
function renderDetailActions(){
|
||||
const d=_detailData;if(!d)return;
|
||||
const run=d.status==='running'||hasPending(d.results);
|
||||
let html='';
|
||||
if(run)html+=`<button onclick="stopExec(${d.id})" class="text-xs px-2 py-1 rounded border border-red-500/50 text-red-300">停止</button>`;
|
||||
if(canRetry(d.status,d.results))html+=`<button onclick="retryExec(${d.id},${!!d.long_task})" class="text-xs px-2 py-1 rounded border border-amber-500/50 text-amber-200">重试</button>`;
|
||||
if(run)html+=`<button onclick="markStuck(${d.id})" class="text-xs px-2 py-1 rounded border border-slate-600 text-slate-400">标记卡住</button>`;
|
||||
html+=`<button onclick="showDetail(${d.id},true)" class="text-xs px-2 py-1 rounded border border-brand/50 text-brand-light">状态+日志</button>`;
|
||||
html+=`<button onclick="hideDetail()" class="text-xs px-2 py-1 rounded border border-slate-700 text-slate-400">关闭</button>`;
|
||||
document.getElementById('detailActions').innerHTML=html;
|
||||
}
|
||||
function renderDetailActions(){
|
||||
const d=_detailData;if(!d)return;
|
||||
const run=d.status==='running'||hasPending(d.results);
|
||||
let html='';
|
||||
if(run)html+=`<button onclick="stopExec(${d.id})" class="text-xs px-2 py-1 rounded border border-red-500/50 text-red-300">停止</button>`;
|
||||
if(canRetry(d.status,d.results))html+=`<button onclick="retryExec(${d.id},${!!d.long_task})" class="text-xs px-2 py-1 rounded border border-amber-500/50 text-amber-200">重试</button>`;
|
||||
if(run)html+=`<button onclick="markStuck(${d.id})" class="text-xs px-2 py-1 rounded border border-slate-600 text-slate-400">标记卡住</button>`;
|
||||
html+=`<button onclick="showDetail(${d.id},true)" class="text-xs px-2 py-1 rounded border border-brand/50 text-brand-light">状态+日志</button>`;
|
||||
html+=`<button onclick="hideDetail()" class="text-xs px-2 py-1 rounded border border-slate-700 text-slate-400">关闭</button>`;
|
||||
document.getElementById('detailActions').innerHTML=html;
|
||||
}
|
||||
|
||||
async function showDetail(id,fetchLogs,silent){
|
||||
_detailId=id;
|
||||
const r=await apiFetch(API+'/scripts/executions/'+id+'?fetch_logs='+(fetchLogs?'true':'false'));
|
||||
if(!r||!r.ok){if(!silent)toast('error','加载失败');return}
|
||||
const d=await r.json();
|
||||
_detailData=d;
|
||||
let text='';
|
||||
if(d.progress)text+=`进度: ${d.progress}\n\n`;
|
||||
if(d.events&&d.events.length){
|
||||
text+='[事件]\n'+d.events.map(e=>`${fmt(e.at)} ${e.action}: ${e.detail}`).join('\n')+'\n\n';
|
||||
}
|
||||
const res=d.results||{};
|
||||
for(const[sid,row]of Object.entries(res)){
|
||||
if(sid==='_meta')continue;
|
||||
text+=`--- 服务器 #${sid} ---\n${JSON.stringify(row,null,2)}\n\n`;
|
||||
}
|
||||
document.getElementById('detailTitle').textContent='执行 #'+id+' · '+(ST[d.status]||d.status);
|
||||
document.getElementById('detailPre').textContent=text||'(空)';
|
||||
document.getElementById('detailPanel').classList.remove('hidden');
|
||||
renderDetailActions();
|
||||
}
|
||||
function hideDetail(){document.getElementById('detailPanel').classList.add('hidden');_detailId=null;_detailData=null}
|
||||
|
||||
async function stopExec(id){
|
||||
if(!confirm('停止该执行中 pending 的子机任务?'))return;
|
||||
const r=await apiFetch(API+'/scripts/executions/'+id+'/stop',{method:'POST',headers:apiHeadersJSON(),body:'{}'});
|
||||
if(r&&r.ok){toast('success','已发送停止');loadList();if(_detailId===id)showDetail(id,false)}
|
||||
else toast('error','停止失败');
|
||||
}
|
||||
async function retryExec(id,longTask){
|
||||
if(!confirm('对失败/未完成的服务器重新执行?'))return;
|
||||
const r=await apiFetch(API+'/scripts/executions/'+id+'/retry',{
|
||||
method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({timeout:120,long_task:!!longTask}),
|
||||
});
|
||||
if(r&&r.ok){
|
||||
const d=await r.json();
|
||||
toast('success','已重新提交 #'+(d.id||id));
|
||||
if(d.id)showDetail(d.id,false);
|
||||
loadList();
|
||||
}else toast('error','重试失败');
|
||||
}
|
||||
async function markStuck(id){
|
||||
const r=await apiFetch(API+'/scripts/executions/'+id+'/mark-stuck',{method:'POST',headers:apiHeadersJSON(),body:'{}'});
|
||||
if(r&&r.ok){toast('success','已标记');loadList();if(_detailId===id)showDetail(id,false)}
|
||||
else toast('error','标记失败');
|
||||
}
|
||||
async function showDetail(id,fetchLogs,silent){
|
||||
_detailId=id;
|
||||
const r=await apiFetch(API+'/scripts/executions/'+id+'?fetch_logs='+(fetchLogs?'true':'false'));
|
||||
if(!r||!r.ok){if(!silent)toast('error','加载失败');return}
|
||||
const d=await r.json();
|
||||
_detailData=d;
|
||||
let text='';
|
||||
if(d.progress)text+=`进度: ${d.progress}\n\n`;
|
||||
if(d.events&&d.events.length){
|
||||
text+='[事件]\n'+d.events.map(e=>`${fmt(e.at)} ${e.action}: ${e.detail}`).join('\n')+'\n\n';
|
||||
}
|
||||
const res=d.results||{};
|
||||
for(const[sid,row]of Object.entries(res)){
|
||||
if(sid==='_meta')continue;
|
||||
text+=`--- 服务器 #${sid} ---\n${JSON.stringify(row,null,2)}\n\n`;
|
||||
}
|
||||
document.getElementById('detailTitle').textContent='执行 #'+id+' · '+(ST[d.status]||d.status);
|
||||
document.getElementById('detailPre').textContent=text||'(空)';
|
||||
document.getElementById('detailPanel').classList.remove('hidden');
|
||||
renderDetailActions();
|
||||
}
|
||||
function hideDetail(){document.getElementById('detailPanel').classList.add('hidden');_detailId=null;_detailData=null}
|
||||
|
||||
async function stopExec(id){
|
||||
if(!confirm('停止该执行中 pending 的子机任务?'))return;
|
||||
const r=await apiFetch(API+'/scripts/executions/'+id+'/stop',{method:'POST',headers:apiHeadersJSON(),body:'{}'});
|
||||
if(r&&r.ok){toast('success','已发送停止');loadList();if(_detailId===id)showDetail(id,false)}
|
||||
else toast('error','停止失败');
|
||||
}
|
||||
async function retryExec(id,longTask){
|
||||
if(!confirm('对失败/未完成的服务器重新执行?'))return;
|
||||
const r=await apiFetch(API+'/scripts/executions/'+id+'/retry',{
|
||||
method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({timeout:120,long_task:!!longTask}),
|
||||
});
|
||||
if(r&&r.ok){
|
||||
const d=await r.json();
|
||||
toast('success','已重新提交 #'+(d.id||id));
|
||||
if(d.id)showDetail(d.id,false);
|
||||
loadList();
|
||||
</script>
|
||||
}else toast('error','重试失败');
|
||||
}
|
||||
async function markStuck(id){
|
||||
const r=await apiFetch(API+'/scripts/executions/'+id+'/mark-stuck',{method:'POST',headers:apiHeadersJSON(),body:'{}'});
|
||||
if(r&&r.ok){toast('success','已标记');loadList();if(_detailId===id)showDetail(id,false)}
|
||||
else toast('error','标记失败');
|
||||
}
|
||||
|
||||
loadList();
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+617
-617
File diff suppressed because it is too large
Load Diff
+1179
-1179
File diff suppressed because it is too large
Load Diff
+893
-893
File diff suppressed because it is too large
Load Diff
+603
-603
File diff suppressed because it is too large
Load Diff
Vendored
+25
-6
@@ -1,8 +1,27 @@
|
||||
/* Nexus Theme System — Tailwind v4 native dark mode */
|
||||
/* Dark mode activated via .dark class on <html> element */
|
||||
/* Nexus Theme System — CSS Variables + Dark Mode */
|
||||
/* Uses Tailwind arbitrary values: bg-[var(--bg-page)] etc. */
|
||||
/* Dark mode toggled via .dark class on <html> element */
|
||||
|
||||
/* Import Tailwind CSS (required for browser build to process directives) */
|
||||
@import "tailwindcss";
|
||||
:root {
|
||||
--bg-page: #f9fafb;
|
||||
--bg-card: #ffffff;
|
||||
--bg-surface: #f3f4f6;
|
||||
--border: #e5e7eb;
|
||||
--border-input: #d1d5db;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--text-muted: #9ca3af;
|
||||
--text-dim: #d1d5db;
|
||||
}
|
||||
|
||||
/* Configure Tailwind v4 to use class-based dark mode */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
:root.dark {
|
||||
--bg-page: #030712;
|
||||
--bg-card: #111827;
|
||||
--bg-surface: #1f2937;
|
||||
--border: #1f2937;
|
||||
--border-input: #374151;
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
--text-dim: #4b5563;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user