5958c21048
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>
894 lines
65 KiB
HTML
894 lines
65 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="/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">×</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">×</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">×</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,''')}
|
||
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>
|