(function (global) { function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); } async function fetchJson(url, opts) { const base = opts || {}; const headers = Object.assign({ Accept: "application/json" }, base.headers || {}); try { const r = await fetch( url, Object.assign({}, base, { credentials: "same-origin", headers: headers, }) ); const text = await r.text(); let data = {}; try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { ok: false, msg: "非 JSON 响应 HTTP " + r.status + (text ? ": " + String(text).slice(0, 120) : ""), }; } if (!data || typeof data !== "object") data = {}; if (data.ok == null && !r.ok) { data.ok = false; data.msg = data.msg || "请求失败 HTTP " + r.status; } return { httpOk: r.ok, status: r.status, data: data }; } catch (e) { return { httpOk: false, status: 0, data: { ok: false, msg: (e && e.message) || "网络错误" }, }; } } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fmtVer(v) { if (!v) return "—"; if (v.online === false) return v.msg || "离线"; const short = v.short_sha || (v.sha ? String(v.sha).slice(0, 7) : ""); const branch = v.branch ? v.branch + " · " : ""; if (!short) return v.msg || (v.ok ? "未知版本" : "不可用"); return branch + short + (v.dirty ? " (dirty)" : ""); } function setStatus(el, text, cls) { if (!el) return; el.textContent = text || ""; el.className = "update-status" + (cls ? " " + cls : ""); } async function waitRecover(versionUrl, beforeSha, statusEl, label, opts) { const options = opts || {}; const expectSha = options.expectSha || ""; const alreadyLatest = !!options.alreadyLatest; let sawDown = false; let stableOnline = 0; const maxTries = 30; for (let i = 0; i < maxTries; i++) { await sleep(1500); const res = await fetchJson(versionUrl); const v = res.data || {}; const online = res.httpOk && (v.ok !== false) && (v.online !== false); if (!online) { sawDown = true; stableOnline = 0; setStatus(statusEl, "重启中 → 等待上线… (" + (i + 1) + "/" + maxTries + ")", "pending"); continue; } const afterSha = v.sha || ""; const afterShort = v.short_sha || (afterSha ? afterSha.slice(0, 7) : ""); const beforeShort = beforeSha ? String(beforeSha).slice(0, 7) : ""; if (expectSha && afterSha && afterSha === expectSha) { setStatus( statusEl, (label || "更新完成") + (beforeSha && beforeSha !== afterSha ? ": " + (beforeShort || "?") + " → " + (afterShort || "?") : " " + (afterShort || "")), "ok" ); return v; } if (afterSha && beforeSha && afterSha !== beforeSha) { setStatus(statusEl, (label || "更新完成") + ": " + (beforeShort || "?") + " → " + (afterShort || "?"), "ok"); return v; } if (sawDown) { setStatus( statusEl, afterSha && beforeSha && afterSha === beforeSha ? "已重启上线(已是最新 " + (afterShort || "") + ")" : "已上线 " + (afterShort || ""), "ok" ); return v; } stableOnline += 1; // 重启很快时可能看不到掉线:连续在线数次且版本符合预期/未变 → 视为完成 if (stableOnline >= 4) { if (alreadyLatest || (afterSha && beforeSha && afterSha === beforeSha)) { setStatus(statusEl, "完成(已是最新 " + (afterShort || "") + ",进程已重启)", "ok"); return v; } if (expectSha && afterSha === expectSha) { setStatus(statusEl, (label || "更新完成") + " " + (afterShort || ""), "ok"); return v; } } setStatus(statusEl, "等待重启生效… (" + (i + 1) + "/" + maxTries + ")", "pending"); } setStatus(statusEl, "超时未确认,请点「刷新版本」或查看 logs/hub_update.log", "err"); return null; } async function refreshHubVersion() { const verEl = document.getElementById("hub-update-version"); if (!verEl) return null; verEl.textContent = "探测中…"; verEl.className = "update-version"; const res = await fetchJson("/api/settings/update/hub/version"); const v = res.data || {}; if (res.httpOk && v.ok) { verEl.textContent = fmtVer(v); verEl.className = "update-version online"; } else { verEl.textContent = v.msg || "读取失败"; verEl.className = "update-version offline"; } return v; } async function runHubUpdate() { const btn = document.getElementById("hub-update-btn"); const statusEl = document.getElementById("hub-update-status"); if (!btn) return; if ( !window.confirm( "确认更新中控?\n\n将执行 git pull + pm2 restart。\n页面会短暂不可用,完成后自动探测版本。" ) ) { return; } btn.disabled = true; setStatus(statusEl, "读取当前版本…", "pending"); const before = await refreshHubVersion(); const beforeSha = (before && before.sha) || ""; setStatus(statusEl, "下发更新指令…", "pending"); const res = await fetchJson("/api/settings/update/hub", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: "{}", }); const data = res.data || {}; if (!data.ok) { const detail = data.msg || (res.status ? "更新失败 HTTP " + res.status : "更新失败(无响应)"); setStatus(statusEl, detail, "err"); btn.disabled = false; return; } setStatus(statusEl, data.msg || "已拉取,等待重启…", "pending"); try { await waitRecover( "/api/settings/update/hub/version", beforeSha || data.before_sha || "", statusEl, "中控更新完成", { expectSha: data.after_sha || "", alreadyLatest: !data.changed, } ); await refreshHubVersion(); } finally { btn.disabled = false; } } async function refreshClientRow(row) { const iid = row.getAttribute("data-id"); const verEl = row.querySelector("[data-client-version]"); if (!iid || !verEl) return null; verEl.textContent = "探测中…"; verEl.className = "update-version"; const res = await fetchJson("/api/instances/" + iid + "/version"); const v = res.data || {}; if (v.online) { verEl.textContent = fmtVer(Object.assign({ online: true }, v)); verEl.className = "update-version online"; } else { verEl.textContent = v.msg || "离线/不可达"; verEl.className = "update-version offline"; } return v; } async function runClientUpdate(row, opts) { const options = opts || {}; const iid = row.getAttribute("data-id"); const name = row.getAttribute("data-name") || ("#" + iid); const btn = row.querySelector("[data-client-update]"); const statusEl = row.querySelector("[data-client-status]"); if (!iid || !btn) return { ok: false, name: name, msg: "无效行" }; if (btn.disabled && !options.forceEnabled) { return { ok: false, name: name, msg: "未启用或忙碌中" }; } if ( !options.skipConfirm && !window.confirm( "确认远程更新「" + name + "」?\n\n将执行 git pull + pm2 restart。\n有持仓时服务会短暂中断,请谨慎。" ) ) { return { ok: false, name: name, msg: "已取消" }; } btn.disabled = true; setStatus(statusEl, "读取当前版本…", "pending"); const before = await refreshClientRow(row); const beforeSha = (before && before.sha) || ""; setStatus(statusEl, "下发更新指令…", "pending"); const res = await fetchJson("/api/instances/" + iid + "/update", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: "{}", }); const data = res.data || {}; if (!data.ok) { const msg = data.msg || (res.status ? "更新失败 HTTP " + res.status : "更新失败"); setStatus(statusEl, msg, "err"); btn.disabled = false; return { ok: false, name: name, msg: msg }; } setStatus(statusEl, "已受理 → 等待上线…", "pending"); try { await waitRecover( "/api/instances/" + iid + "/version", beforeSha || data.before_sha || "", statusEl, "更新完成" ); await refreshClientRow(row); return { ok: true, name: name }; } finally { btn.disabled = false; } } async function runAllClientUpdates() { const allBtn = document.getElementById("client-update-all"); const allStatus = document.getElementById("client-update-all-status"); const rows = Array.prototype.slice .call(document.querySelectorAll(".client-update-row")) .filter(function (row) { const btn = row.querySelector("[data-client-update]"); return btn && !btn.disabled; }); if (!rows.length) { setStatus(allStatus, "没有可更新的启用实例", "err"); return; } if ( !window.confirm( "确认远程更新全部 " + rows.length + " 台启用实例?\n\n将依次执行 git pull + pm2 restart。\n有持仓时服务会短暂中断,请谨慎。" ) ) { return; } if (allBtn) allBtn.disabled = true; const refreshBtn = document.getElementById("client-update-refresh"); if (refreshBtn) refreshBtn.disabled = true; let okCount = 0; let failCount = 0; try { for (let i = 0; i < rows.length; i++) { const row = rows[i]; const name = row.getAttribute("data-name") || "#" + (i + 1); setStatus( allStatus, "全部更新中 " + (i + 1) + "/" + rows.length + " · " + name, "pending" ); const result = await runClientUpdate(row, { skipConfirm: true }); if (result && result.ok) okCount += 1; else failCount += 1; } if (failCount === 0) { setStatus(allStatus, "全部更新完成 · " + okCount + " 台", "ok"); } else { setStatus( allStatus, "全部更新结束 · 成功 " + okCount + " · 失败 " + failCount, "err" ); } } finally { if (allBtn) allBtn.disabled = false; if (refreshBtn) refreshBtn.disabled = false; } } async function loadClients() { const body = document.getElementById("client-update-body"); if (!body) return; body.innerHTML = '