Files
Nexus/web/app/push.html
T
Your Name c03fe97d18 UX优化: 同步日志显示服务器名 + redis_url可编辑
- 同步日志API: JOIN Server表返回server_name字段
- 推送历史: 显示服务器名称替代操作人
- 仪表盘最近同步: 显示服务器名称替代server_id
- redis_url从SENSITIVE_KEYS移除(非密码,用户需可编辑)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:10:20 +08:00

237 lines
14 KiB
HTML

<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 推送</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">批量推送</h1></div>
</header>
<main class="flex-1 overflow-y-auto p-6 max-w-3xl mx-auto w-full">
<!-- Push Form -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div><label class="block text-sm text-slate-300 mb-2">源路径</label><input id="srcPath" placeholder="/home/deploy/source" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
<div><label class="block text-sm text-slate-300 mb-2">目标路径</label><input id="destPath" placeholder="默认使用服务器配置的目标路径" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
<div><label class="block text-sm text-slate-300 mb-2">目标服务器</label>
<div class="flex gap-2 mb-2">
<button onclick="selectAllServers()" class="text-xs text-brand-light hover:underline">全选</button>
<button onclick="deselectAllServers()" class="text-xs text-slate-400 hover:underline">全不选</button>
</div>
<select id="targetServers" multiple class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm h-40"></select>
<div id="serverCount" class="text-slate-500 text-xs mt-1">已选择 0 台服务器</div>
</div>
<div><label class="block text-sm text-slate-300 mb-2">同步模式</label>
<div class="grid grid-cols-3 gap-3">
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<input type="radio" name="syncMode" value="incremental" checked class="accent-brand"> <span class="text-sm">增量同步</span>
</label>
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<input type="radio" name="syncMode" value="full" class="accent-brand"> <span class="text-sm">全量同步</span>
</label>
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<input type="radio" name="syncMode" value="checksum" class="accent-brand"> <span class="text-sm">校验和</span>
</label>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="block text-sm text-slate-300 mb-2">并发数</label><input id="concurrency" type="number" value="10" min="1" max="50" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
<div><label class="block text-sm text-slate-300 mb-2">批次大小</label><input id="batchSize" type="number" value="50" min="1" max="200" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
</div>
<button onclick="doPush()" id="pushBtn" class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition disabled:opacity-50 disabled:cursor-not-allowed">开始推送</button>
</div>
<!-- Progress Section -->
<div id="progressSection" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-white font-semibold">推送进度</h2>
<span id="progressStats" class="text-sm text-slate-400">0/0</span>
</div>
<!-- Progress Bar -->
<div class="w-full bg-slate-800 rounded-full h-3 overflow-hidden">
<div id="progressBar" class="h-full bg-brand rounded-full transition-all duration-500" style="width:0%"></div>
</div>
<div class="flex gap-4 text-sm">
<span class="text-green-400">✓ 成功: <span id="countSuccess">0</span></span>
<span class="text-red-400">✗ 失败: <span id="countFailed">0</span></span>
<span class="text-slate-400">◌ 等待: <span id="countPending">0</span></span>
</div>
<!-- Per-server Results -->
<div id="serverResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
</div>
<!-- History Section -->
<div class="mt-6">
<h2 class="text-white font-semibold mb-3">最近推送记录</h2>
<div id="pushHistory" class="space-y-2"><div class="text-slate-500 text-center py-6 text-sm">加载中...</div></div>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
initLayout('push');
let _serversCache=[];
let _pushInProgress=false;
async function loadServers(){
const r=await apiFetch(API+'/servers/');if(!r)return;
const data=await r.json();_serversCache=data.items||data;
const sel=document.getElementById('targetServers');
sel.innerHTML=_serversCache.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
// Pre-select servers from batch push (servers.html)
const batchIds=sessionStorage.getItem('batchPushIds');
if(batchIds){
try{
const ids=JSON.parse(batchIds);
Array.from(sel.options).forEach(o=>{
if(ids.includes(parseInt(o.value)))o.selected=true;
});
sessionStorage.removeItem('batchPushIds');
toast('info',`已预选 ${ids.length} 台服务器`);
}catch(e){}
}
updateServerCount();
}
document.getElementById('targetServers').addEventListener('change',updateServerCount);
function updateServerCount(){
const sel=document.getElementById('targetServers').selectedOptions.length;
document.getElementById('serverCount').textContent=`已选择 ${sel} 台服务器`;
}
function selectAllServers(){
const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=true);updateServerCount();
}
function deselectAllServers(){
const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=false);updateServerCount();
}
async function doPush(){
if(_pushInProgress)return;
const opts=document.getElementById('targetServers').selectedOptions;
const ids=Array.from(opts).map(o=>parseInt(o.value));
if(!ids.length){toast('warning','请选择至少一台服务器');return}
const srcPath=document.getElementById('srcPath').value;
if(!srcPath){toast('warning','请输入源路径');return}
const syncMode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
const body={
server_ids:ids,
source_path:srcPath,
target_path:document.getElementById('destPath').value||null,
sync_mode:syncMode,
concurrency:parseInt(document.getElementById('concurrency').value)||10,
batch_size:parseInt(document.getElementById('batchSize').value)||50,
};
_pushInProgress=true;
const btn=document.getElementById('pushBtn');btn.disabled=true;btn.textContent='推送中...';
// Show progress section with pending states
showProgress(ids);
try{
const r=await apiFetch(API+'/sync/files',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){toast('error','认证失败或请求被拒绝');resetProgress();return}
const d=await r.json();
updateProgressFromResult(d);
toast(d.failed>0?'warning':'success',`推送完成: ${d.completed||0} 成功, ${d.failed||0} 失败`);
}catch(e){
toast('error','推送请求失败: '+e.message);
resetProgress();
}finally{
_pushInProgress=false;btn.disabled=false;btn.textContent='开始推送';
loadHistory(); // Refresh history
}
}
function showProgress(ids){
const section=document.getElementById('progressSection');section.classList.remove('hidden');
document.getElementById('progressBar').style.width='0%';
document.getElementById('progressStats').textContent=`0/${ids.length}`;
document.getElementById('countSuccess').textContent='0';
document.getElementById('countFailed').textContent='0';
document.getElementById('countPending').textContent=ids.length;
// Build per-server rows
const serversMap={};_serversCache.forEach(s=>serversMap[s.id]=s);
document.getElementById('serverResults').innerHTML=ids.map(id=>{
const s=serversMap[id];
return `<div id="srv_${id}" class="flex items-center gap-3 px-3 py-2 bg-slate-800/50 rounded-lg">
<span class="shrink-0 w-5 h-5 flex items-center justify-center text-slate-400" id="srv_icon_${id}">◌</span>
<span class="text-sm text-slate-300 flex-1">${esc(s?.name||'服务器#'+id)}</span>
<span class="text-xs text-slate-500" id="srv_status_${id}">等待中</span>
<span class="text-xs text-slate-600 hidden" id="srv_detail_${id}"></span>
</div>`;
}).join('');
}
function updateProgressFromResult(d){
const total=d.total||0;const completed=d.completed||0;const failed=d.failed||0;
const pct=total>0?Math.round(((completed+failed)/total)*100):0;
document.getElementById('progressBar').style.width=pct+'%';
document.getElementById('progressStats').textContent=`${completed+failed}/${total}`;
document.getElementById('countSuccess').textContent=completed;
document.getElementById('countFailed').textContent=failed;
document.getElementById('countPending').textContent=total-completed-failed;
const serversMap={};_serversCache.forEach(s=>serversMap[s.id]=s);
if(d.results){
for(const[sid,log]of Object.entries(d.results)){
const iconEl=document.getElementById('srv_icon_'+sid);
const statusEl=document.getElementById('srv_status_'+sid);
const detailEl=document.getElementById('srv_detail_'+sid);
if(!iconEl)continue;
if(log.status==='success'){
iconEl.textContent='✓';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-green-400';
statusEl.textContent='成功';statusEl.className='text-xs text-green-400';
if(log.duration_seconds){detailEl.textContent=log.duration_seconds+'s';detailEl.classList.remove('hidden')}
}else{
iconEl.textContent='✗';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-red-400';
statusEl.textContent='失败';statusEl.className='text-xs text-red-400';
if(log.error_message){detailEl.textContent=log.error_message.substring(0,60);detailEl.classList.remove('hidden');detailEl.className='text-xs text-red-400/70'}
}
}
}
// Mark remaining as pending
document.querySelectorAll('[id^="srv_status_"]').forEach(el=>{
if(el.textContent==='等待中'){
const id=el.id.replace('srv_status_','');
const iconEl=document.getElementById('srv_icon_'+id);
if(iconEl){iconEl.textContent='◌';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-slate-500'}
el.className='text-xs text-slate-500';
}
});
}
function resetProgress(){
document.getElementById('progressSection').classList.add('hidden');
}
// ── Push History (recent sync logs) ──
async function loadHistory(){
try{
const r=await apiFetch(API+'/servers/logs?limit=10');if(!r)return;
const logs=await r.json();
document.getElementById('pushHistory').innerHTML=logs.length?logs.map(l=>`<div class="bg-slate-900 rounded-lg border border-slate-800 px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="${l.status==='success'?'text-green-400':'text-red-400'} text-sm">${l.status==='success'?'✓':'✗'}</span>
<div>
<span class="text-sm text-slate-300">${esc(l.server_name||'#'+l.server_id)}</span>
<span class="text-slate-600 mx-1">·</span>
<span class="text-xs text-slate-500">${esc(l.operator||'system')}</span>
<span class="text-slate-600 mx-1">·</span>
<span class="text-xs text-slate-500">${esc(l.sync_mode||'file')}</span>
</div>
</div>
<div class="text-xs text-slate-500">${fmtTime(l.started_at)}</div>
</div>`).join(''):'<div class="text-slate-500 text-center py-6 text-sm">暂无推送记录</div>';
}catch(e){
document.getElementById('pushHistory').innerHTML='<div class="text-slate-500 text-center py-6 text-sm">加载失败</div>';
}
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function fmtTime(t){if(!t)return'';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
loadServers();
loadHistory();
</script>
</body></html>