diff --git a/static/settings_update.js b/static/settings_update.js
index ad968cd..780e517 100644
--- a/static/settings_update.js
+++ b/static/settings_update.js
@@ -1,339 +1,400 @@
-(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) {
- 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;
- if (
- !window.confirm(
- "确认远程更新「" +
- name +
- "」?\n\n将执行 git pull + pm2 restart。\n有持仓时服务会短暂中断,请谨慎。"
- )
- ) {
- return;
- }
- 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) {
- setStatus(
- statusEl,
- data.msg || (res.status ? "更新失败 HTTP " + res.status : "更新失败"),
- "err"
- );
- btn.disabled = false;
- return;
- }
- setStatus(statusEl, "已受理 → 等待上线…", "pending");
- try {
- await waitRecover(
- "/api/instances/" + iid + "/version",
- beforeSha || data.before_sha || "",
- statusEl,
- "更新完成"
- );
- await refreshClientRow(row);
- } finally {
- btn.disabled = false;
- }
- }
-
- async function loadClients() {
- const body = document.getElementById("client-update-body");
- if (!body) return;
- body.innerHTML = '
| 加载中… |
';
- try {
- const res = await fetchJson("/api/settings/update/clients");
- const items = (res.data && res.data.items) || [];
- if (!items.length) {
- body.innerHTML = '| 暂无登记实例 |
';
- return;
- }
- body.innerHTML = items
- .map(function (it) {
- const id = it.id;
- const name = it.name || "#" + id;
- const enabled = !!it.enabled;
- return (
- '' +
- "| " +
- escapeHtml(name) +
- " " +
- escapeHtml(it.base_url || "") +
- " | " +
- '— | ' +
- "" +
- (enabled ? "启用" : '停用') +
- " | " +
- '' +
- ' " +
- '' +
- ' ' +
- " | " +
- "
"
- );
- })
- .join("");
-
- body.querySelectorAll(".client-update-row").forEach(function (row) {
- const upd = row.querySelector("[data-client-update]");
- const ref = row.querySelector("[data-client-refresh]");
- if (upd) {
- upd.addEventListener("click", function () {
- runClientUpdate(row);
- });
- }
- if (ref) {
- ref.addEventListener("click", function () {
- refreshClientRow(row);
- });
- }
- refreshClientRow(row);
- });
- } catch (e) {
- body.innerHTML =
- '| ' + escapeHtml((e && e.message) || "加载失败") + " |
";
- }
- }
-
- function boot() {
- const hubBtn = document.getElementById("hub-update-btn");
- const hubRefresh = document.getElementById("hub-update-refresh");
- const clientsRefresh = document.getElementById("client-update-refresh");
- if (hubBtn) hubBtn.addEventListener("click", runHubUpdate);
- if (hubRefresh) hubRefresh.addEventListener("click", refreshHubVersion);
- if (clientsRefresh) clientsRefresh.addEventListener("click", loadClients);
- refreshHubVersion();
- loadClients();
- }
-
- global.HubSettingsUpdate = { boot: boot };
-})(window);
+(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 = '| 加载中… |
';
+ try {
+ const res = await fetchJson("/api/settings/update/clients");
+ const items = (res.data && res.data.items) || [];
+ if (!items.length) {
+ body.innerHTML = '| 暂无登记实例 |
';
+ return;
+ }
+ body.innerHTML = items
+ .map(function (it) {
+ const id = it.id;
+ const name = it.name || "#" + id;
+ const enabled = !!it.enabled;
+ return (
+ '' +
+ "| " +
+ escapeHtml(name) +
+ " " +
+ escapeHtml(it.base_url || "") +
+ " | " +
+ '— | ' +
+ "" +
+ (enabled ? "启用" : '停用') +
+ " | " +
+ '' +
+ ' " +
+ '' +
+ ' ' +
+ " | " +
+ "
"
+ );
+ })
+ .join("");
+
+ body.querySelectorAll(".client-update-row").forEach(function (row) {
+ const upd = row.querySelector("[data-client-update]");
+ const ref = row.querySelector("[data-client-refresh]");
+ if (upd) {
+ upd.addEventListener("click", function () {
+ runClientUpdate(row);
+ });
+ }
+ if (ref) {
+ ref.addEventListener("click", function () {
+ refreshClientRow(row);
+ });
+ }
+ refreshClientRow(row);
+ });
+ } catch (e) {
+ body.innerHTML =
+ '| ' + escapeHtml((e && e.message) || "加载失败") + " |
";
+ }
+ }
+
+ function boot() {
+ const hubBtn = document.getElementById("hub-update-btn");
+ const hubRefresh = document.getElementById("hub-update-refresh");
+ const clientsRefresh = document.getElementById("client-update-refresh");
+ const clientsAll = document.getElementById("client-update-all");
+ if (hubBtn) hubBtn.addEventListener("click", runHubUpdate);
+ if (hubRefresh) hubRefresh.addEventListener("click", refreshHubVersion);
+ if (clientsRefresh) clientsRefresh.addEventListener("click", loadClients);
+ if (clientsAll) clientsAll.addEventListener("click", runAllClientUpdates);
+ refreshHubVersion();
+ loadClients();
+ }
+
+ global.HubSettingsUpdate = { boot: boot };
+})(window);
diff --git a/templates/settings.html b/templates/settings.html
index 226b905..a5acb30 100644
--- a/templates/settings.html
+++ b/templates/settings.html
@@ -166,7 +166,9 @@
客户端远程更新
经 Bridge 探测版本并下发更新;客户端需已具备 /api/hub/update
+
+
@@ -183,7 +185,7 @@
{% endblock %}
{% block scripts %}
-
+