Files
Nexus/web/app/push.html
T
Your Name 5958c21048
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
feat: 推送页面 Round 4 — 服务器搜索 / Diff对比 / 排错诊断 / 源路径输入 / 批量重试
5 项新功能:
- H2: 服务器搜索过滤 — 目标服务器下拉加搜索框实时过滤
- H3: 推送对比 Diff 视图 — 预览文件列表加 diff 按钮对比本地与远程差异
- H4: 推送排错面板 — 失败行加诊断按钮检查SSH/磁盘/权限
- H5: 源路径直接输入 — 支持直接输入服务器本地路径推送
- H6: 批量重试 — 一键重试所有失败服务器

新增后端端点:
- POST /api/sync/validate-source-path (H5)
- POST /api/sync/diagnose (H4)
- POST /api/sync/file-diff (H3)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:29:41 +08:00

894 lines
65 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 推送</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<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">
<!-- Template selector -->
<div class="flex items-center gap-2">
<select id="templateSelect" onchange="applyTemplate()" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300">
<option value="">📋 推送模板...</option>
</select>
<button onclick="saveTemplate()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition whitespace-nowrap" title="保存当前配置为模板">💾 保存</button>
<button onclick="deleteTemplate()" class="px-3 py-2 bg-slate-800 hover:bg-red-900/50 text-slate-400 text-sm rounded-lg transition" title="删除选中模板">🗑</button>
</div>
<!-- Source: ZIP upload (drag & drop + click) -->
<div>
<label class="text-sm text-slate-300 mb-2 block">上传 ZIP</label>
<div id="sourceUpload" class="space-y-2">
<div id="dropZone" class="relative flex items-center justify-center gap-3 px-6 py-8 border-2 border-dashed border-slate-700 rounded-xl cursor-pointer transition hover:border-brand hover:bg-brand/5"
onclick="document.getElementById('zipFile').click()"
ondragover="event.preventDefault();this.classList.add('border-brand','bg-brand/5')"
ondragleave="this.classList.remove('border-brand','bg-brand/5')"
ondrop="event.preventDefault();this.classList.remove('border-brand','bg-brand/5');handleDrop(event)">
<input id="zipFile" type="file" accept=".zip" class="hidden" onchange="uploadZip()">
<svg class="w-8 h-8 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
<div>
<p class="text-sm text-slate-300">拖拽 ZIP 文件到此处,或 <span class="text-brand-light hover:underline">点击选择</span></p>
<p class="text-xs text-slate-500 mt-1">仅支持 .zip 格式</p>
</div>
</div>
<div id="uploadProgress" class="hidden flex items-center gap-2 text-sm text-slate-400">
<div class="animate-spin w-4 h-4 border-2 border-brand border-t-transparent rounded-full"></div>
上传并解压中...
</div>
<div id="uploadResult" class="hidden space-y-2">
<div class="flex items-center gap-3 px-3 py-2 bg-green-900/20 border border-green-700/30 rounded-lg">
<span class="text-green-400 text-sm"></span>
<span id="uploadedInfo" class="text-xs text-slate-400"></span>
<button onclick="clearUpload()" class="text-xs text-slate-500 hover:text-slate-300 ml-auto">✕ 清除</button>
</div>
<!-- File Manager -->
<div id="fileManager" class="bg-slate-800/50 border border-slate-700/50 rounded-lg overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700/50">
<span class="text-xs text-slate-400">📁</span>
<span id="fmBreadcrumb" class="text-xs text-slate-300 flex-1 truncate"></span>
<span id="fmCount" class="text-xs text-slate-500"></span>
</div>
<div id="fmEntries" class="max-h-64 overflow-y-auto"></div>
</div>
</div>
</div>
</div>
<!-- H5: Source path direct input -->
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500 shrink-0">或输入源路径:</span>
<input id="sourcePathInput" placeholder="/path/to/files" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-xs placeholder-slate-600">
<button onclick="validateSourcePath()" id="validatePathBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition whitespace-nowrap">验证路径</button>
<span id="sourcePathResult" class="hidden text-xs"></span>
</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 flex-wrap items-center gap-2 mb-2">
<select id="filterCategory" onchange="filterServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300">
<option value="">全部分类</option>
</select>
<select id="filterPlatform" onchange="filterServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300">
<option value="">全部平台</option>
</select>
<button onclick="selectAllServers()" class="text-xs text-brand-light hover:underline">全选</button>
<button onclick="deselectAllServers()" class="text-xs text-slate-400 hover:underline">全不选</button>
<button onclick="selectFilteredServers()" class="text-xs text-brand-light hover:underline">选中当前筛选</button>
</div>
<input id="serverSearch" placeholder="搜索服务器名称/域名..." oninput="filterServerOptions()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300 placeholder-slate-600 mb-1">
<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" onchange="onSyncModeChange()"> <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" onchange="onSyncModeChange()"> <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" onchange="onSyncModeChange()"> <span class="text-sm">校验和</span>
</label>
</div>
<div id="syncModeDesc" class="text-xs text-slate-500 mt-2 px-1">仅传输源目录中新增或修改的文件,目标已有且未变的文件跳过。安全快速,适合日常更新。</div>
</div>
<!-- Full sync warning + preview -->
<div id="fullSyncWarning" class="hidden rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 space-y-2">
<div class="flex items-start gap-2">
<span class="text-amber-400 text-base leading-none mt-0.5"></span>
<div>
<p class="text-amber-300 text-sm font-medium">全量同步将删除目标目录中不存在于源目录的文件</p>
<p class="text-amber-400/70 text-xs mt-0.5">建议推送前先对首台服务器运行预览,确认变动范围</p>
</div>
</div>
<button onclick="doPreview()" id="previewBtn"
class="w-full py-2 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/50 text-amber-300 text-sm font-medium rounded-lg transition">
预览首台 — rsync --dry-run --stats
</button>
</div>
<div id="lightPreview" class="flex items-center justify-end">
<button onclick="doPreview()" class="text-xs text-slate-500 hover:text-slate-300 transition">预览首台变动 →</button>
</div>
<!-- Preview result -->
<div id="previewResult" class="hidden rounded-xl border border-slate-700 bg-slate-800/50 p-4 space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-slate-200" id="previewServerName">预览中...</span>
<button onclick="clearPreview()" class="text-xs text-slate-500 hover:text-slate-300">✕ 关闭</button>
</div>
<div id="previewStats" class="grid grid-cols-2 gap-2 text-sm"></div>
<div id="previewVerboseToggle" class="hidden">
<button onclick="doPreview(true)" class="text-xs text-brand-light hover:underline">显示详细文件列表 ▼</button>
</div>
<div id="previewFileList" class="hidden">
<pre id="previewFiles" class="text-xs text-slate-400 bg-slate-900 rounded-lg p-3 max-h-56 overflow-y-auto whitespace-pre-wrap"></pre>
<p id="previewTruncated" class="hidden text-xs text-slate-500 mt-1"></p>
</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>
<!-- Action buttons -->
<div class="flex gap-3">
<button onclick="doPush()" id="pushBtn" class="flex-1 py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition disabled:opacity-50 disabled:cursor-not-allowed">开始推送</button>
<button onclick="showScheduleModal()" class="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium rounded-xl transition text-sm whitespace-nowrap">⏰ 定时推送</button>
</div>
<label class="flex items-center gap-2 text-sm text-slate-400 -mt-2">
<input type="checkbox" id="verifyAfterPush" class="accent-brand"> 推送后校验(md5sum 对比)
</label>
</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>
<div class="flex items-center gap-3">
<span id="progressStats" class="text-sm text-slate-400">0/0</span>
<button id="cancelPushBtn" onclick="cancelPush()" class="hidden px-3 py-1 bg-red-500/20 hover:bg-red-500/30 border border-red-500/50 text-red-400 text-xs rounded-lg transition">取消推送</button>
</div>
</div>
<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>
<button id="retryAllBtn" class="hidden text-xs text-brand-light hover:underline ml-2" onclick="retryAllFailed()">🔄 重试全部失败</button>
</div>
<div id="serverResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
</div>
<!-- History Section -->
<div class="mt-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-white font-semibold">推送记录</h2>
<div class="flex items-center gap-2">
<select id="historyStatus" onchange="loadHistory(1)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300">
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failed">失败</option>
<option value="cancelled">已取消</option>
</select>
<input id="historyServer" placeholder="服务器ID" class="w-20 px-2 py-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300" onchange="loadHistory(1)">
</div>
</div>
<div id="pushHistory" class="space-y-2"><div class="text-slate-500 text-center py-6 text-sm">加载中...</div></div>
<div id="historyPager" class="hidden flex items-center justify-center gap-2 mt-3 text-xs text-slate-400">
<button onclick="loadHistory(_histPage-1)" id="histPrev" class="px-2 py-1 bg-slate-800 rounded hover:bg-slate-700 disabled:opacity-30">上一页</button>
<span id="histPageInfo">1 / 1</span>
<button onclick="loadHistory(_histPage+1)" id="histNext" class="px-2 py-1 bg-slate-800 rounded hover:bg-slate-700 disabled:opacity-30">下一页</button>
</div>
</div>
<!-- Verify Result Section -->
<div id="verifySection" 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>
<button onclick="document.getElementById('verifySection').classList.add('hidden')" class="text-xs text-slate-500 hover:text-slate-300">✕ 关闭</button>
</div>
<div id="verifyResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
</div>
<!-- File Preview Modal -->
<div id="filePreviewModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-[640px] max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-800">
<div class="flex items-center gap-2">
<span class="text-slate-400">📄</span>
<span id="previewFileName" class="text-sm text-slate-200 font-medium"></span>
<span id="previewFileSize" class="text-xs text-slate-500"></span>
</div>
<button onclick="document.getElementById('filePreviewModal').classList.add('hidden')" class="text-slate-500 hover:text-white text-lg">&times;</button>
</div>
<pre id="previewFileContent" class="flex-1 overflow-auto px-5 py-4 text-xs text-slate-300 font-mono whitespace-pre-wrap break-all"></pre>
</div>
</div>
<!-- Schedule Modal -->
<div id="scheduleModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-md p-6 space-y-4">
<h2 class="text-white font-semibold text-lg">⏰ 定时推送</h2>
<p class="text-sm text-slate-400">将当前推送配置保存为一次性调度任务,到指定时间自动执行。</p>
<div>
<label class="block text-slate-400 text-xs mb-1">执行时间 *</label>
<input id="schedFireAt" type="datetime-local" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
</div>
<div id="schedSummary" class="text-xs text-slate-500 bg-slate-800/50 rounded-lg px-3 py-2"></div>
<div class="flex gap-2">
<button onclick="doSchedulePush()" class="flex-1 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm font-medium">确认调度</button>
<button onclick="document.getElementById('scheduleModal').classList.add('hidden')" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
</div>
</div>
</div>
<!-- H4: Diagnose Modal -->
<div id="diagModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-md p-6 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-white font-semibold">🔍 推送诊断</h2>
<button onclick="document.getElementById('diagModal').classList.add('hidden')" class="text-slate-500 hover:text-white text-lg">&times;</button>
</div>
<div id="diagServerName" class="text-sm text-slate-400"></div>
<div id="diagResults" class="space-y-3"></div>
</div>
</div>
<!-- H3: File Diff Modal -->
<div id="diffModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-[720px] max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-800">
<div class="flex items-center gap-2">
<span class="text-slate-400">📄</span>
<span id="diffFileName" class="text-sm text-slate-200 font-medium"></span>
<span id="diffFileInfo" class="text-xs text-slate-500"></span>
</div>
<button onclick="document.getElementById('diffModal').classList.add('hidden')" class="text-slate-500 hover:text-white text-lg">&times;</button>
</div>
<div id="diffContent" class="flex-1 overflow-auto px-3 py-3 font-mono text-xs leading-5"></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;
let _uploadSourcePath='';
let _fmCurrentPath='';
let _pushBatchId='';
let _pushWs=null;
let _histPage=1;
let _histPages=1;
let _previewServerId=null;
// ── Templates (G3) ──
const TPL_KEY='nexus_push_templates';
function loadTemplates(){
const sel=document.getElementById('templateSelect');
let tpls=[];
try{tpls=JSON.parse(localStorage.getItem(TPL_KEY)||'[]')}catch(e){}
sel.innerHTML='<option value="">📋 推送模板...</option>'+
tpls.map((t,i)=>`<option value="${i}">${esc(t.name)} (${t.server_ids?t.server_ids.length:0}台 · ${t.sync_mode||'incremental'})</option>`).join('');
}
function applyTemplate(){
const idx=document.getElementById('templateSelect').value;
if(idx==='')return;
let tpls=[];try{tpls=JSON.parse(localStorage.getItem(TPL_KEY)||'[]')}catch(e){}
const t=tpls[parseInt(idx)];if(!t)return;
if(t.source_path){_uploadSourcePath=t.source_path;document.getElementById('uploadResult').classList.remove('hidden');document.getElementById('uploadedInfo').textContent='模板: '+t.source_path}
if(t.target_path)document.getElementById('destPath').value=t.target_path;
if(t.sync_mode){const r=document.querySelector(`input[name="syncMode"][value="${t.sync_mode}"]`);if(r){r.checked=true;onSyncModeChange()}}
if(t.server_ids&&t.server_ids.length){
const sel=document.getElementById('targetServers');
Array.from(sel.options).forEach(o=>{o.selected=t.server_ids.includes(parseInt(o.value))});
updateServerCount();
}
toast('info','已加载模板: '+t.name);
}
function saveTemplate(){
const name=prompt('模板名称:');if(!name)return;
const opts=document.getElementById('targetServers').selectedOptions;
const ids=Array.from(opts).map(o=>parseInt(o.value));
const mode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
const tpl={name,source_path:_uploadSourcePath,target_path:document.getElementById('destPath').value,sync_mode:mode,server_ids:ids};
let tpls=[];try{tpls=JSON.parse(localStorage.getItem(TPL_KEY)||'[]')}catch(e){}
tpls.push(tpl);localStorage.setItem(TPL_KEY,JSON.stringify(tpls));
loadTemplates();toast('success','模板已保存: '+name);
}
function deleteTemplate(){
const idx=document.getElementById('templateSelect').value;if(idx==='')return;
let tpls=[];try{tpls=JSON.parse(localStorage.getItem(TPL_KEY)||'[]')}catch(e){}
const name=tpls[parseInt(idx)]?.name;tpls.splice(parseInt(idx),1);
localStorage.setItem(TPL_KEY,JSON.stringify(tpls));loadTemplates();
toast('success','已删除模板'+(name?': '+name:''));
}
// ── Drag & Drop (G6) ──
function handleDrop(ev){
const file=ev.dataTransfer.files[0];
if(!file)return;
if(!file.name.toLowerCase().endsWith('.zip')){toast('warning','仅支持 .zip 格式');return}
document.getElementById('zipFile').files=ev.dataTransfer.files;
uploadZip();
}
// ── WebSocket for real-time push progress ──
function connectPushWS(){
if(_pushWs&&_pushWs.readyState<=1)return;
const token=localStorage.getItem('access_token');if(!token)return;
const proto=location.protocol==='https:'?'wss:':'ws:';
const url=proto+'//'+location.host+'/ws/alerts?token='+encodeURIComponent(token);
try{
_pushWs=new WebSocket(url);
_pushWs.onmessage=function(ev){
try{const msg=JSON.parse(ev.data);if(msg.type==='sync_progress'&&msg.batch_id===_pushBatchId)updateProgressFromWS(msg)}catch(e){}
};
_pushWs.onclose=function(){_pushWs=null;if(_pushInProgress)setTimeout(connectPushWS,3000)};
_pushWs.onerror=function(){_pushWs=null};
}catch(e){_pushWs=null}
}
function updateProgressFromWS(msg){
const sid=msg.server_id;
const iconEl=document.getElementById('srv_icon_'+sid);
const statusEl=document.getElementById('srv_status_'+sid);
const detailEl=document.getElementById('srv_detail_'+sid);
const retryEl=document.getElementById('srv_retry_'+sid);
const diagEl=document.getElementById('srv_diag_'+sid);
if(!iconEl||!statusEl)return;
if(msg.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(msg.duration_seconds!=null){detailEl.textContent=msg.duration_seconds+'s';detailEl.classList.remove('hidden');detailEl.className='text-xs text-green-400/70'}
if(retryEl)retryEl.classList.add('hidden');
if(diagEl)diagEl.classList.add('hidden');
}else if(msg.status==='cancelled'){
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-slate-400';
statusEl.textContent='已取消';statusEl.className='text-xs text-slate-400';
detailEl.textContent='推送已取消';detailEl.classList.remove('hidden');detailEl.className='text-xs text-slate-500';
if(retryEl)retryEl.classList.add('hidden');
if(diagEl)diagEl.classList.add('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(msg.error_message){detailEl.textContent=msg.error_message.substring(0,80);detailEl.classList.remove('hidden');detailEl.className='text-xs text-red-400/70'}
if(retryEl&&msg.retry_job_id){retryEl.dataset.retryJobId=msg.retry_job_id;retryEl.classList.remove('hidden')}
if(diagEl)diagEl.classList.remove('hidden');
}
// Update progress bar and counters
const total=msg.total||0;
const cancelledCount=msg.cancelled||0;
const done=msg.completed+msg.failed+cancelledCount;
const pct=total>0?Math.round((done/total)*100):0;
document.getElementById('progressBar').style.width=pct+'%';
document.getElementById('progressStats').textContent=done+'/'+total;
document.getElementById('countSuccess').textContent=msg.completed;
document.getElementById('countFailed').textContent=msg.failed;
document.getElementById('countPending').textContent=Math.max(0,total-done);
// H6: Show retry-all button if there are failures
const retryAllBtn=document.getElementById('retryAllBtn');
if(msg.failed>0&&done>=total)retryAllBtn.classList.remove('hidden');
else retryAllBtn.classList.add('hidden');
}
async function uploadZip(){
const file=document.getElementById('zipFile').files[0];
if(!file){toast('warning','请选择 ZIP 文件');return}
if(!file.name.toLowerCase().endsWith('.zip')){toast('warning','仅支持 .zip 格式');return}
const form=new FormData();form.append('file',file);
const btn=document.getElementById('pushBtn');
btn.disabled=true;btn.textContent='上传中...';
document.getElementById('uploadProgress').classList.remove('hidden');
document.getElementById('uploadResult').classList.add('hidden');
try{
const r=await apiFetch(API+'/sync/upload-zip',{method:'POST',body:form});
if(!r){toast('error','上传失败:认证错误');return}
const d=await r.json();
if(!r.ok){toast('error','上传失败: '+(d.detail||r.status));return}
_uploadSourcePath=d.source_path;
document.getElementById('uploadProgress').classList.add('hidden');
document.getElementById('uploadResult').classList.remove('hidden');
document.getElementById('uploadedInfo').textContent=`已解压 ${d.file_count} 个文件 (${fmtBytes(d.size)})`;
browseLocal(d.source_path);
toast('success',`ZIP 解压完成: ${d.file_count} 个文件`);
}catch(e){
toast('error','上传异常: '+e.message);
document.getElementById('uploadProgress').classList.add('hidden');
}finally{
btn.disabled=false;btn.textContent='开始推送';
}
}
function clearUpload(){
_uploadSourcePath='';_fmCurrentPath='';
document.getElementById('zipFile').value='';
document.getElementById('uploadResult').classList.add('hidden');
document.getElementById('sourcePathInput').value='';
document.getElementById('sourcePathResult').classList.add('hidden');
}
// ── File Manager (browse local upload dir) ──
async function browseLocal(path){
_fmCurrentPath=path;
const rel=path.replace(_uploadSourcePath,'').replace(/^\//,'');
document.getElementById('fmBreadcrumb').textContent=rel?'📁 '+rel:'📁 /';
document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-slate-500">加载中...</div>';
try{
const r=await apiFetch(API+'/sync/browse-local',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({path})});
if(!r){toast('error','浏览失败');return}
const d=await r.json();if(!r.ok){toast('error','浏览失败: '+(d.detail||r.status));return}
renderFileManager(d);
}catch(e){document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-red-400">加载失败</div>'}
}
function renderFileManager(d){
const entries=d.entries||[];
const dirs=entries.filter(e=>e.is_dir);
const files=entries.filter(e=>!e.is_dir);
document.getElementById('fmCount').textContent=`${dirs.length} 目录, ${files.length} 文件`;
let html='';
if(d.path!==_uploadSourcePath){
const parent=d.path.substring(0,d.path.lastIndexOf('/'))||_uploadSourcePath;
html+=`<div class="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(parent)}')"><span class="text-slate-400">📁</span><span class="text-slate-300">..</span></div>`;
}
for(const e of dirs){
const fullPath=d.path+'/'+e.name;
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(fullPath)}')"><span class="text-brand-light">📁</span><span class="text-slate-200 flex-1 truncate">${esc(e.name)}</span><span class="hidden group-hover:flex items-center gap-1 shrink-0"><button onclick="event.stopPropagation();renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-slate-300 px-1" title="重命名">✏</button><button onclick="event.stopPropagation();deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-red-400 px-1" title="删除">🗑</button></span></div>`;
}
for(const e of files){
const fullPath=d.path+'/'+e.name;
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 text-xs"><span class="text-slate-500">📄</span><span class="text-slate-300 flex-1 truncate cursor-pointer hover:text-brand-light transition" onclick="previewLocalFile('${esc(fullPath)}','${esc(e.name)}')" title="点击预览">${esc(e.name)}</span><span class="text-slate-600 shrink-0">${fmtBytes(e.size)}</span><span class="hidden group-hover:flex items-center gap-1 shrink-0"><button onclick="renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-slate-300 px-1" title="重命名">✏</button><button onclick="deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-red-400 px-1" title="删除">🗑</button></span></div>`;
}
if(!dirs.length&&!files.length)html='<div class="px-3 py-2 text-xs text-slate-500">空目录</div>';
document.getElementById('fmEntries').innerHTML=html;
}
async function deleteLocalFile(path,name){
if(!confirm(`确认删除 "${name}"`))return;
try{const r=await apiFetch(API+'/sync/local-file-ops',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({operation:'delete',path})});if(!r){toast('error','删除失败');return}const d=await r.json();if(!r.ok){toast('error','删除失败: '+(d.detail||r.status));return}toast('success','已删除: '+name);browseLocal(_fmCurrentPath)}catch(e){toast('error','删除异常: '+e.message)}
}
async function renameLocalFile(path,oldName){
const newName=prompt('输入新名称:',oldName);if(!newName||newName===oldName)return;
try{const r=await apiFetch(API+'/sync/local-file-ops',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({operation:'rename',path,new_name:newName})});if(!r){toast('error','重命名失败');return}const d=await r.json();if(!r.ok){toast('error','重命名失败: '+(d.detail||r.status));return}toast('success',`已重命名: ${oldName}${newName}`);browseLocal(_fmCurrentPath)}catch(e){toast('error','重命名异常: '+e.message)}
}
async function previewLocalFile(path,name){
try{const r=await apiFetch(API+'/sync/local-file-preview',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({path})});if(!r){toast('error','预览失败');return}const d=await r.json();if(!r.ok){toast('error','预览失败: '+(d.detail||r.status));return}
document.getElementById('previewFileName').textContent=name||d.name;
document.getElementById('previewFileSize').textContent=fmtBytes(d.size)+(d.truncated?' (仅显示前4KB)':'');
if(d.encoding_hint==='binary'){document.getElementById('previewFileContent').textContent='[二进制文件,无法预览]'}
else{const decoded=atob(d.content_base64);document.getElementById('previewFileContent').textContent=decoded}
document.getElementById('filePreviewModal').classList.remove('hidden');
}catch(e){toast('error','预览异常: '+e.message)}
}
async function loadServers(){
try{
const cr=await apiFetch(API+'/servers/categories');if(cr){
const cd=await cr.json();
const catSel=document.getElementById('filterCategory');
const platSel=document.getElementById('filterPlatform');
(cd.categories||[]).forEach(c=>{const o=document.createElement('option');o.value=c.name;o.textContent=c.name+' ('+c.count+')';catSel.appendChild(o)});
(cd.platforms||[]).forEach(p=>{const o=document.createElement('option');o.value=p.id;o.textContent=p.name+(p.category?' ('+p.category+')':'');platSel.appendChild(o)});
}
}catch(e){}
await filterServers();
const batchIds=sessionStorage.getItem('batchPushIds');
if(batchIds){try{const ids=JSON.parse(batchIds);const sel=document.getElementById('targetServers');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();
}
async function filterServers(){
const cat=document.getElementById('filterCategory').value;
const plat=document.getElementById('filterPlatform').value;
let url=API+'/servers/?per_page=200';
if(cat)url+='&category='+encodeURIComponent(cat);
if(plat)url+='&platform_id='+plat;
const r=await apiFetch(url);if(!r)return;
const data=await r.json();_serversCache=data.items||data;
const sel=document.getElementById('targetServers');
const prevSelected=new Set(Array.from(sel.selectedOptions).map(o=>parseInt(o.value)));
sel.innerHTML=_serversCache.map(s=>{
const online=s.is_online;const icon=online?'🟢':'🔴';const style=online?'':'color:#666';
return `<option value="${s.id}"${prevSelected.has(s.id)?' selected':''} style="${style}">${icon} ${esc(s.name)} (${esc(s.domain)})</option>`;
}).join('');
updateServerCount();
filterServerOptions();
}
// H2: Server search filter
function filterServerOptions(){
const q=(document.getElementById('serverSearch').value||'').toLowerCase().trim();
const sel=document.getElementById('targetServers');
Array.from(sel.options).forEach(o=>{
const text=(o.textContent||'').toLowerCase();
o.style.display=(!q||text.includes(q))?'':'none';
});
}
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()}
function selectFilteredServers(){const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=true);updateServerCount()}
// ── Push (with cancel + notification) ──
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=_uploadSourcePath;if(!srcPath){toast('warning','请先上传 ZIP 文件');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;_pushBatchId='';
const btn=document.getElementById('pushBtn');btn.disabled=true;btn.textContent='推送中...';
// G5: Request notification permission
if('Notification' in window && Notification.permission==='default'){Notification.requestPermission()}
showProgress(ids);connectPushWS();
let pushCompleted=null;
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();if(d.batch_id)_pushBatchId=d.batch_id;
updateProgressFromResult(d);
pushCompleted=d;
toast(d.failed>0?'warning':'success',`推送完成: ${d.completed||0} 成功, ${d.failed||0} 失败`);
if(document.getElementById('verifyAfterPush').checked&&d.completed>0)doVerify(ids,_uploadSourcePath,document.getElementById('destPath').value||null);
}catch(e){toast('error','推送请求失败: '+e.message);resetProgress()}
finally{
_pushInProgress=false;btn.disabled=false;btn.textContent='开始推送';
document.getElementById('cancelPushBtn').classList.add('hidden');
if(_pushWs){try{_pushWs.close()}catch(e){}_pushWs=null}
loadHistory(1);
// G5: Browser notification
if(pushCompleted){
const completed=pushCompleted.completed||0;const failed=pushCompleted.failed||0;
if('Notification' in window && Notification.permission==='granted'){
new Notification('Nexus 推送完成',{body:`${completed} 成功, ${failed} 失败`,icon:'/app/favicon.ico'});
}else{
// Fallback: title blink
const orig=document.title;let count=0;
const iv=setInterval(()=>{document.title=count%2===0?'🔔 Nexus — 推送完成':orig;count++;if(count>=6){clearInterval(iv);document.title=orig}},800);
}
}
}
}
// G1: Cancel push
async function cancelPush(){
if(!_pushBatchId){toast('warning','无进行中的推送');return}
if(!confirm('确认取消推送?已完成的服务器将保留结果。'))return;
try{
const r=await apiFetch(API+'/sync/cancel',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({batch_id:_pushBatchId})});
if(!r){toast('error','取消请求失败');return}
const d=await r.json();if(!r.ok){toast('error','取消失败: '+(d.detail||r.status));return}
toast('success','已发送取消指令');
document.getElementById('cancelPushBtn').classList.add('hidden');
}catch(e){toast('error','取消异常: '+e.message)}
}
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;
document.getElementById('cancelPushBtn').classList.remove('hidden');
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>
<button id="srv_retry_${id}" class="hidden text-xs text-brand-light hover:underline shrink-0" onclick="retryServer(this)">🔄 重试</button>
<button id="srv_diag_${id}" class="hidden text-xs text-slate-400 hover:text-amber-400 shrink-0" onclick="diagnoseServer(${id})">🔍 诊断</button>
</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;
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);
const diagEl=document.getElementById('srv_diag_'+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')}
if(diagEl)diagEl.classList.add('hidden');
}else if(log.status==='cancelled'){
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-slate-400';
statusEl.textContent='已取消';statusEl.className='text-xs text-slate-400';
if(diagEl)diagEl.classList.add('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'}
if(log.retry_job_id){const retryEl=document.getElementById('srv_retry_'+sid);if(retryEl){retryEl.dataset.retryJobId=log.retry_job_id;retryEl.classList.remove('hidden')}}
if(diagEl)diagEl.classList.remove('hidden');
}
}
}
// H6: Show retry-all button if there are failures
const retryAllBtn=document.getElementById('retryAllBtn');
if(failed>0&&completed+failed>=total)retryAllBtn.classList.remove('hidden');
else retryAllBtn.classList.add('hidden');
}
function resetProgress(){document.getElementById('progressSection').classList.add('hidden')}
async function retryServer(btn){
const jobId=btn.dataset.retryJobId;if(!jobId){toast('warning','无重试任务ID');return}
btn.disabled=true;btn.textContent='重试中...';
try{const r=await apiFetch(API+'/retries/'+jobId+'/retry',{method:'POST',headers:apiHeadersJSON()});if(!r){toast('error','重试请求失败');return}const d=await r.json();if(!r.ok){toast('error','重试失败: '+(d.detail||r.status));return}toast('success','已提交重试任务 #'+jobId);btn.classList.add('hidden')}catch(e){toast('error','重试异常: '+e.message)}finally{btn.disabled=false;btn.textContent='🔄 重试'}
}
// ── H6: Batch retry all failed ──
async function retryAllFailed(){
const retryBtns=document.querySelectorAll('[id^="srv_retry_"]:not(.hidden)');
const jobs=[];
retryBtns.forEach(btn=>{const jid=btn.dataset.retryJobId;if(jid)jobs.push({btn,jid})});
if(!jobs.length){toast('warning','无可用重试任务');return}
if(!confirm(`确认重试 ${jobs.length} 个失败任务?`))return;
let ok=0,fail=0;
for(const{btn,jid} of jobs){
btn.disabled=true;btn.textContent='重试中...';
try{
const r=await apiFetch(API+'/retries/'+jid+'/retry',{method:'POST',headers:apiHeadersJSON()});
if(r&&r.ok){ok++;btn.classList.add('hidden')}else{fail++}
}catch(e){fail++}
finally{btn.disabled=false;btn.textContent='🔄 重试'}
}
toast(ok>0?'success':'warning',`重试: ${ok} 成功, ${fail} 失败`);
document.getElementById('retryAllBtn').classList.add('hidden');
}
// ── H5: Validate source path ──
async function validateSourcePath(){
const path=document.getElementById('sourcePathInput').value.trim();
if(!path){toast('warning','请输入源路径');return}
const btn=document.getElementById('validatePathBtn');
const resultEl=document.getElementById('sourcePathResult');
btn.disabled=true;btn.textContent='验证中...';
try{
const r=await apiFetch(API+'/sync/validate-source-path',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({path})});
if(!r){toast('error','验证失败');return}
const d=await r.json();
if(!r.ok){resultEl.textContent='✗ '+(d.detail||'验证失败');resultEl.className='text-xs text-red-400';resultEl.classList.remove('hidden');return}
_uploadSourcePath=d.path;
resultEl.textContent=`✓ ${d.file_count} 文件, ${fmtBytes(d.size_bytes)}`;
resultEl.className='text-xs text-green-400';
resultEl.classList.remove('hidden');
toast('success','源路径有效: '+d.file_count+' 个文件');
}catch(e){toast('error','验证异常: '+e.message)}
finally{btn.disabled=false;btn.textContent='验证路径'}
}
// ── H4: Diagnose push failure ──
async function diagnoseServer(serverId){
const targetPath=document.getElementById('destPath').value||null;
const modal=document.getElementById('diagModal');
document.getElementById('diagServerName').textContent='服务器 #'+serverId;
document.getElementById('diagResults').innerHTML='<div class="text-slate-500 text-sm text-center py-4">诊断中...</div>';
modal.classList.remove('hidden');
try{
const body={server_id:serverId,target_path:targetPath};
const r=await apiFetch(API+'/sync/diagnose',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){document.getElementById('diagResults').innerHTML='<div class="text-red-400 text-sm">诊断请求失败</div>';return}
const d=await r.json();if(!r.ok){document.getElementById('diagResults').innerHTML=`<div class="text-red-400 text-sm">${esc(d.detail||'诊断失败')}</div>`;return}
document.getElementById('diagServerName').textContent=esc(d.server_name||'#'+d.server_id);
const checks=[
{label:'SSH 连通性',ok:d.ssh_ok,detail:d.ssh_ok?'连接正常':(d.errors.find(e=>e.includes('SSH'))||'连接失败')},
{label:'磁盘空间',ok:d.disk_avail!=null,detail:d.disk_avail?`可用 ${d.disk_avail} (使用 ${d.disk_used_pct||'?'}%)`:'未检测'},
{label:'目标路径',ok:d.path_exists,detail:d.path_exists?`存在 (${d.path_perms||'?'})`:(d.errors.find(e=>e.includes('路径'))||'不存在')},
{label:'写入权限',ok:d.path_writable,detail:d.path_writable?'可写入':(d.errors.find(e=>e.includes('写'))||'不可写')},
];
document.getElementById('diagResults').innerHTML=checks.map(c=>`
<div class="flex items-start gap-3 px-3 py-2 ${c.ok?'bg-green-900/10 border border-green-700/20':'bg-red-900/10 border border-red-700/20'} rounded-lg">
<span class="${c.ok?'text-green-400':'text-red-400'} text-sm mt-0.5">${c.ok?'✓':'✗'}</span>
<div><div class="text-sm text-slate-200">${esc(c.label)}</div><div class="text-xs ${c.ok?'text-slate-400':'text-red-400/80'}">${esc(c.detail)}</div></div>
</div>`).join('');
}catch(e){document.getElementById('diagResults').innerHTML=`<div class="text-red-400 text-sm">诊断异常: ${esc(e.message)}</div>`}
}
// ── H3: File diff view ──
async function showFileDiff(serverId,relativePath){
const srcPath=_uploadSourcePath;if(!srcPath){toast('warning','无源路径');return}
const targetPath=document.getElementById('destPath').value||null;
const modal=document.getElementById('diffModal');
document.getElementById('diffFileName').textContent=relativePath;
document.getElementById('diffFileInfo').textContent='加载中...';
document.getElementById('diffContent').innerHTML='';
modal.classList.remove('hidden');
try{
const body={server_id:serverId,source_path:srcPath,relative_path:relativePath,target_path:targetPath};
const r=await apiFetch(API+'/sync/file-diff',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){toast('error','对比失败');return}
const d=await r.json();if(!r.ok){toast('error','对比失败: '+(d.detail||r.status));return}
let info='';
if(d.local_exists&&d.remote_exists)info=`本地 ${fmtBytes(d.local_size)} → 远程 ${fmtBytes(d.remote_size)}`;
else if(d.local_exists)info=`仅本地 ${fmtBytes(d.local_size)} (新增)`;
else if(d.remote_exists)info=`仅远程 ${fmtBytes(d.remote_size)} (删除)`;
if(d.truncated)info+=' (截断至100KB)';
document.getElementById('diffFileInfo').textContent=info;
const lines=d.diff_lines||[];
if(!lines.length){document.getElementById('diffContent').innerHTML='<div class="text-slate-500 text-center py-4">文件内容相同,无差异</div>';return}
document.getElementById('diffContent').innerHTML=lines.map(l=>{
if(l.type==='header')return `<div class="text-cyan-400 font-bold">${esc(l.content)}</div>`;
if(l.type==='add')return `<div class="bg-green-900/30 text-green-300 pl-2">${esc(l.content)}</div>`;
if(l.type==='del')return `<div class="bg-red-900/30 text-red-300 pl-2">${esc(l.content)}</div>`;
return `<div class="text-slate-500 pl-2">${esc(l.content)}</div>`;
}).join('');
}catch(e){toast('error','对比异常: '+e.message)}
}
// ── G2: Schedule push ──
function showScheduleModal(){
const opts=document.getElementById('targetServers').selectedOptions;
const ids=Array.from(opts).map(o=>parseInt(o.value));
if(!ids.length){toast('warning','请选择至少一台服务器');return}
const srcPath=_uploadSourcePath;if(!srcPath){toast('warning','请先上传 ZIP 文件');return}
const mode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
document.getElementById('schedSummary').textContent=`${ids.length} 台服务器 · ${mode} · ${srcPath}`;
// Set default time to 1 hour from now
const dt=new Date(Date.now()+3600000);
const local=new Date(dt.getTime()-dt.getTimezoneOffset()*60000);
document.getElementById('schedFireAt').value=local.toISOString().slice(0,16);
document.getElementById('scheduleModal').classList.remove('hidden');
}
async function doSchedulePush(){
const fireAt=document.getElementById('schedFireAt').value;if(!fireAt){toast('warning','请选择执行时间');return}
const opts=document.getElementById('targetServers').selectedOptions;
const ids=Array.from(opts).map(o=>parseInt(o.value));
const srcPath=_uploadSourcePath;const mode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
const body={name:'推送-'+new Date(fireAt).toLocaleString('zh-CN'),schedule_type:'push',run_mode:'once',fire_at:new Date(fireAt).toISOString(),source_path:srcPath,sync_mode:mode,server_ids:JSON.stringify(ids),enabled:true};
try{
const r=await apiFetch(API+'/schedules/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){toast('error','调度创建失败');return}
const d=await r.json();if(!r.ok){toast('error','调度失败: '+(d.detail||r.status));return}
toast('success','定时推送已创建,将于 '+new Date(fireAt).toLocaleString('zh-CN')+' 执行');
document.getElementById('scheduleModal').classList.add('hidden');
}catch(e){toast('error','调度异常: '+e.message)}
}
// ── Post-Push Verify (md5sum) ──
async function doVerify(ids,sourcePath,targetPath){
const section=document.getElementById('verifySection');section.classList.remove('hidden');
document.getElementById('verifyResults').innerHTML='<div class="text-slate-500 text-sm text-center py-4">校验中...</div>';
try{
const body={server_ids:ids,source_path:sourcePath,target_path:targetPath||null};
const r=await apiFetch(API+'/sync/verify',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){document.getElementById('verifyResults').innerHTML='<div class="text-red-400 text-sm">校验请求失败</div>';return}
const d=await r.json();if(!r.ok){document.getElementById('verifyResults').innerHTML=`<div class="text-red-400 text-sm">${esc(d.detail||'校验失败')}</div>`;return}
const results=d.results||{};
document.getElementById('verifyResults').innerHTML=Object.values(results).map(v=>{
if(v.error)return `<div class="bg-slate-800/50 rounded-lg px-4 py-3 border-l-2 border-l-red-500"><span class="text-red-400 text-sm">✗</span> <span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span><span class="text-xs text-red-400 ml-2">${esc(v.error)}</span></div>`;
const allOk=v.missing===0&&v.mismatched===0;const border=allOk?'border-l-green-500':'border-l-amber-500';
let detail='';
if(v.missing>0)detail+=`<div class="text-xs text-amber-400 mt-1">缺失 ${v.missing} 文件: ${v.missing_files.slice(0,5).map(esc).join(', ')}${v.missing>5?' ...':''}</div>`;
if(v.mismatched>0)detail+=`<div class="text-xs text-red-400 mt-1">不一致 ${v.mismatched} 文件: ${v.mismatched_files.slice(0,5).map(esc).join(', ')}${v.mismatched>5?' ...':''}</div>`;
return `<div class="bg-slate-800/50 rounded-lg px-4 py-3 border-l-2 ${border}"><span class="${allOk?'text-green-400':'text-amber-400'} text-sm">${allOk?'✓':'⚠'}</span><span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span><span class="text-xs text-slate-500 ml-2">${v.matched} 匹配${v.missing>0?`, ${v.missing} 缺失`:''}${v.mismatched>0?`, ${v.mismatched} 不一致`:''}</span>${detail}</div>`;
}).join('');
}catch(e){document.getElementById('verifyResults').innerHTML=`<div class="text-red-400 text-sm">校验异常: ${esc(e.message)}</div>`}
}
// ── Push History (G4: paginated + filtered) ──
async function loadHistory(page){
page=Math.max(1,page||1);_histPage=page;
const status=document.getElementById('historyStatus')?.value||'';
const serverId=document.getElementById('historyServer')?.value||'';
let url=API+'/servers/logs?page='+page+'&per_page=15';
if(status)url+='&status='+encodeURIComponent(status);
if(serverId&&!isNaN(parseInt(serverId)))url+='&server_id='+parseInt(serverId);
try{
const r=await apiFetch(url);if(!r)return;
const res=await r.json();
const logs=res.items||res;const total=res.total||logs.length;const pages=res.pages||1;_histPages=pages;
document.getElementById('pushHistory').innerHTML=logs.length?logs.map(l=>{
const ok=l.status==='success';const cancelled=l.status==='cancelled';
const border=cancelled?'border-l-2 border-l-slate-500':ok?'border-l-2 border-l-green-500':'border-l-2 border-l-red-500';
let summary=`${esc(l.source_path||'')}${esc(l.target_path||'')}`;if(l.duration_seconds!=null)summary+=` · ${l.duration_seconds}s`;
let detail='';
if(l.files_transferred!=null)detail+=`<div class="text-xs text-slate-400">传输: ${l.files_transferred} 文件</div>`;
if(l.bytes_transferred!=null&&l.bytes_transferred>0)detail+=`<div class="text-xs text-slate-400">大小: ${fmtBytes(l.bytes_transferred)}</div>`;
if(l.error_message)detail+=`<div class="text-xs text-red-400/80 break-all">${esc(l.error_message.substring(0,300))}</div>`;
if(l.diff_summary)detail+=`<details class="mt-1"><summary class="text-xs text-slate-500 cursor-pointer hover:text-slate-300">查看 diff</summary><pre class="text-xs text-slate-500 bg-slate-900 rounded p-2 mt-1 max-h-40 overflow-y-auto whitespace-pre-wrap">${esc(l.diff_summary.substring(0,2000))}</pre></details>`;
const statusLabel=cancelled?'⊘ 已取消':ok?'✓':'✗';
const statusCls=cancelled?'text-slate-400':ok?'text-green-400':'text-red-400';
return `<div class="bg-slate-900 rounded-lg border border-slate-800 ${border} px-4 py-3 cursor-pointer hover:bg-slate-800/50 transition" onclick="this.querySelector('.hist-detail')?.classList.toggle('hidden')">
<div class="flex items-center justify-between"><div class="flex items-center gap-3"><span class="${statusCls} text-sm">${statusLabel}</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>
<div class="text-xs text-slate-500 mt-1 truncate">${summary}</div>
<div class="hist-detail hidden mt-2 space-y-1 border-t border-slate-700/50 pt-2">${detail}</div>
</div>`;
}).join(''):'<div class="text-slate-500 text-center py-6 text-sm">暂无推送记录</div>';
// Pagination
const pager=document.getElementById('historyPager');
if(pages>1){
pager.classList.remove('hidden');
document.getElementById('histPageInfo').textContent=`${page} / ${pages}`;
document.getElementById('histPrev').disabled=page<=1;
document.getElementById('histNext').disabled=page>=pages;
}else{pager.classList.add('hidden')}
}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.replace(/'/g,'&#39;')}
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'})}
// ── Sync mode switch ──
function onSyncModeChange(){
const mode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
const isDestructive=mode==='full'||mode==='overwrite';
document.getElementById('fullSyncWarning').classList.toggle('hidden',!isDestructive);
document.getElementById('lightPreview').classList.toggle('hidden',isDestructive);
clearPreview();
const descEl=document.getElementById('syncModeDesc');
const descs={incremental:'仅传输源目录中新增或修改的文件,目标已有且未变的文件跳过。安全快速,适合日常更新。',full:'使目标目录与源目录完全一致,会删除目标中不存在于源的文件。⚠️ 可能造成数据丢失,建议先预览。',overwrite:'直接覆盖目标文件(--inplace),不创建临时文件。写入中途断电可能导致文件损坏,适合大文件局部更新。',checksum:'通过文件内容校验和(而非修改时间+大小)判断是否需要传输。更精确但更慢,适合时钟不同步的环境。'};
if(descEl)descEl.textContent=descs[mode]||'';
}
// ── Preview (dry-run) ──
async function doPreview(verbose=false){
const opts=document.getElementById('targetServers').selectedOptions;const ids=Array.from(opts).map(o=>parseInt(o.value));
if(!ids.length){toast('warning','请先选择服务器');return}
const srcPath=_uploadSourcePath;if(!srcPath){toast('warning','请先上传 ZIP 文件');return}
const firstServerId=ids[0];_previewServerId=firstServerId;const syncMode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
const btn=document.getElementById('previewBtn');const serverName=_serversCache.find(s=>s.id===firstServerId)?.name||('#'+firstServerId);
const resultEl=document.getElementById('previewResult');resultEl.classList.remove('hidden');
document.getElementById('previewServerName').textContent=`预览中:${esc(serverName)}...`;
document.getElementById('previewStats').innerHTML='<span class="text-slate-500 text-xs col-span-2">正在运行 rsync --dry-run...</span>';
document.getElementById('previewVerboseToggle').classList.add('hidden');document.getElementById('previewFileList').classList.add('hidden');
if(btn)btn.disabled=true;
try{
const body={server_id:firstServerId,source_path:srcPath,target_path:document.getElementById('destPath').value||null,sync_mode:syncMode,verbose};
const r=await apiFetch(API+'/sync/preview',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){toast('error','预览请求失败');return}
const d=await r.json();if(!r.ok){toast('error','预览失败: '+(d.detail||r.status));renderPreviewError(serverName,d.detail);return}
renderPreviewResult(serverName,d,verbose);
}catch(e){toast('error','预览异常: '+e.message);renderPreviewError(serverName,e.message)}finally{if(btn)btn.disabled=false}
}
function renderPreviewResult(serverName,d,verbose){
document.getElementById('previewServerName').textContent=`预览结果:${esc(serverName)}`;
const s=d.stats||{};const isDestructive=(d.sync_mode==='full'||d.sync_mode==='overwrite');
document.getElementById('previewStats').innerHTML=`
<div class="bg-slate-900 rounded-lg px-3 py-2"><div class="text-slate-400 text-xs mb-1">待传输文件</div><div class="text-white font-semibold text-lg">${s.files_transferred??'—'}</div>${s.transfer_size_bytes!=null?`<div class="text-slate-500 text-xs">${fmtBytes(s.transfer_size_bytes)}</div>`:''}</div>
<div class="bg-slate-900 rounded-lg px-3 py-2"><div class="text-slate-400 text-xs mb-1">新增文件</div><div class="text-green-400 font-semibold text-lg">${s.files_created??'—'}</div>${s.files_total!=null?`<div class="text-slate-500 text-xs">共 ${s.files_total} 个文件</div>`:''}</div>
${isDestructive&&s.files_deleted!=null?`<div class="bg-red-900/30 border border-red-500/30 rounded-lg px-3 py-2 col-span-2"><div class="text-red-400 text-xs mb-1">⚠ 将删除文件(--delete</div><div class="text-red-400 font-semibold text-lg">${s.files_deleted} 个文件</div></div>`:(s.files_deleted?`<div class="bg-slate-900 rounded-lg px-3 py-2"><div class="text-slate-400 text-xs mb-1">删除文件</div><div class="text-red-400 font-semibold text-lg">${s.files_deleted}</div></div>`:'')}`;
if(!verbose)document.getElementById('previewVerboseToggle').classList.remove('hidden');
if(verbose&&d.files&&d.files.length>0){document.getElementById('previewFileList').classList.remove('hidden');
document.getElementById('previewFiles').innerHTML=d.files.map(f=>`<div class="flex items-center gap-2"><span class="flex-1 truncate">${esc(f)}</span><button onclick="showFileDiff(${_previewServerId},'${esc(f).replace(/'/g,"\\'")}')" class="text-xs text-brand-light hover:underline shrink-0">📄 diff</button></div>`).join('\n');
const trunc=document.getElementById('previewTruncated');if(d.files_truncated){trunc.textContent='(仅显示前 200 条,实际可能更多)';trunc.classList.remove('hidden')}else{trunc.classList.add('hidden')}}
}
function renderPreviewError(serverName,msg){document.getElementById('previewServerName').textContent=`预览失败:${esc(serverName)}`;document.getElementById('previewStats').innerHTML=`<div class="text-red-400 text-sm col-span-2">${esc(msg||'未知错误')}</div>`}
function clearPreview(){document.getElementById('previewResult').classList.add('hidden')}
function fmtBytes(b){if(!b)return'';if(b<1024)return b+' B';if(b<1024*1024)return(b/1024).toFixed(1)+' KB';if(b<1024**3)return(b/1024/1024).toFixed(1)+' MB';return(b/1024**3).toFixed(2)+' GB'}
loadTemplates();
loadServers();
loadHistory(1);
</script>
</body></html>