From 4a6d71844b559443668ac6724f1fc88c8972660f Mon Sep 17 00:00:00 2001 From: Gloridust Date: Mon, 6 Jul 2026 23:56:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(update):=20=E6=9B=B4=E6=96=B0=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=E4=B8=93=E9=A1=B9=E5=AE=A1=E6=9F=A5=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=BF=9C=E7=AB=AF=E6=96=B0=E7=89=88=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E7=9B=B2=E5=8C=BA=20+=20=E5=8D=95=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=BC=82=E6=AD=A5=E5=8C=96=20+=20=E8=87=AA?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对面板自更新/单实例升级/一键升级三条链路审查后逐项修复: 盲区(最重要):实例"可升级"检测只比「容器镜像 vs 本地镜像」,而用户更新面板后本地实例 镜像通常还是旧的(没人主动 pull)→ 永远检测不到可升级、升级引导形同虚设。新增远端检测: registry manifest HEAD digest(不下载)对比本地 RepoDigests,30 分钟缓存、后台刷新、 离线/自构建镜像返回未知不打扰。横幅与关于页提示均纳入该信号。 单实例升级异步化:原同步等待拉取(受限网络下数分钟)会被反代 ~60s 掐断 → 前端误报失败 而后台还在跑,再点一次就并发重建。改为登记 upgradingIds 后立即返回,前端轮询直至完成, 按"是否仍落后"给出结论;与一键升级互斥(409)。 一键升级顺序修正:先拉镜像再判定落后清单(原先反了——拉取带来更新后,"本来等于旧最新版" 的实例才变落后,点完全部升级横幅却还在);拉取阶段进度显示 phase;页面刷新后自动恢复轮询。 自更新加固:拉取加无进度停滞超时(原来会无限卡死且 updateInFlight 永久锁死);标志 10 分钟 自动复位(helper 静默失败后可重试);版本锚定(R1)——优先拉更新检查宣告的具体版本 tag, 失败回退 :latest,保证"更新到 v1.3.1"拿到的就是 v1.3.1。 其他:并发 pullImage 合并为单一拉取;升级保留实例原有停止状态(不再悄悄拉起); 单实例升级完成后也回收悬空镜像。 Co-Authored-By: Claude Opus 4.8 (1M context) --- panel/server/src/docker.ts | 136 +++++++++++++++++++++++++++++++- panel/server/src/index.ts | 106 ++++++++++++++++--------- panel/server/src/self-update.ts | 57 ++++++++++++- panel/web/src/api.ts | 10 ++- panel/web/src/pages/Admin.tsx | 118 +++++++++++++++++++-------- 5 files changed, 346 insertions(+), 81 deletions(-) diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index 5b510b2..c505aa5 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -306,7 +306,17 @@ export async function upgradeInstance(inst: Instance, opts?: { skipPull?: boolea console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e); } } + // 升级不改变用户的运行状态:原本停止的实例,升级(重建)后停回去,而不是悄悄拉起。 + const wasStopped = (await instanceRuntime(inst)) === 'stopped'; await runInstance(inst); + if (wasStopped) { + try { + await stopInstance(inst); + appendInstanceLog(inst.id, '升级完成,恢复原有的停止状态'); + } catch { + /* 停不回去也不算失败 */ + } + } } // 清理悬空(dangling)镜像:升级后旧实例镜像失去 tag 变成 ,长期堆积吃磁盘 @@ -512,6 +522,116 @@ export async function instanceOutdated(inst: Instance, latestId: string | null): } } +// ---------- 远端实例镜像新版检测 ---------- +// 盲区背景:instanceOutdated 只比「容器镜像 vs 本地镜像」。用户更新面板后,本地实例镜像 +// 往往还是旧的(没人主动 pull)→ 检测恒为"无可升级"→ 升级引导永远不出现。这里用 registry +// manifest digest(HEAD 请求,不下载)对比本地镜像的 RepoDigests,判断远端是否有新版。 +// best-effort:离线/被墙/私有源 → null(未知,不打扰);本地自构建镜像(无 RepoDigests)→ null。 +let remoteImageCache: { val: boolean | null; at: number } = { val: null, at: 0 }; +let remoteImageInflight: Promise | null = null; +export function invalidateRemoteImageCache(): void { + remoteImageCache = { val: null, at: 0 }; +} +// 同步返回缓存值(可能 null=未知),过期时后台刷新——upgrade-status 是管理页高频接口,不能被 8s 外呼拖住。 +export function remoteInstanceImageNewer(): boolean | null { + const TTL = 30 * 60 * 1000; + if (Date.now() - remoteImageCache.at >= TTL && !remoteImageInflight) { + remoteImageInflight = checkRemoteImageNewer() + .then((v) => { + remoteImageCache = { val: v, at: Date.now() }; + }) + .catch(() => { + remoteImageCache = { val: null, at: Date.now() }; + }) + .finally(() => { + remoteImageInflight = null; + }); + } + return remoteImageCache.at ? remoteImageCache.val : null; +} + +async function checkRemoteImageNewer(): Promise { + let local: any; + try { + local = await docker.getImage(WECHAT_IMAGE).inspect(); + } catch { + return null; // 本地还没有镜像:首次拉取走 ensureImage 流程,不在这里打扰 + } + const repoDigests: string[] = local.RepoDigests || []; + if (!repoDigests.length) return null; // 本地自构建(无 registry 来源)→ 无从比较,不打扰 + const ref = parseImageRef(WECHAT_IMAGE); + if (!ref) return null; + const remote = await fetchManifestDigest(ref); + if (!remote) return null; + return !repoDigests.some((d) => d.endsWith('@' + remote)); +} + +// 解析镜像引用 → { registry, repo, tag }。例:docker.io/gloridust/wechat-on-cloud:latest。 +function parseImageRef(image: string): { registry: string; repo: string; tag: string } | null { + const noDigest = image.split('@')[0]; + const segs = noDigest.split('/'); + let registry = 'docker.io'; + if (segs.length > 1 && (segs[0].includes('.') || segs[0].includes(':'))) registry = segs.shift() as string; + let last = segs[segs.length - 1] || ''; + let tag = 'latest'; + const ti = last.lastIndexOf(':'); + if (ti > 0) { + tag = last.slice(ti + 1); + segs[segs.length - 1] = last.slice(0, ti); + } + const repo = segs.join('/'); + return repo ? { registry, repo, tag } : null; +} + +async function fetchJsonWithTimeout(url: string, headers: Record, ms = 8000): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), ms); + try { + const res = await fetch(url, { headers, signal: ctrl.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } finally { + clearTimeout(t); + } +} + +// 取 registry 上该 tag 的 manifest digest(多架构 index 的 digest,与本地 RepoDigests 同层级)。 +async function fetchManifestDigest(ref: { registry: string; repo: string; tag: string }): Promise { + const accept = + 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json'; + let host = ref.registry; + let token = ''; + try { + if (ref.registry === 'docker.io') { + host = 'registry-1.docker.io'; + const d = await fetchJsonWithTimeout( + `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${ref.repo}:pull`, + {}, + ); + token = d?.token || ''; + } else if (ref.registry === 'ghcr.io') { + const d = await fetchJsonWithTimeout(`https://ghcr.io/token?service=ghcr.io&scope=repository:${ref.repo}:pull`, {}); + token = d?.token || ''; + } + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 8000); + try { + // HEAD 即可拿 Docker-Content-Digest(不下载 manifest 本体) + const res = await fetch(`https://${host}/v2/${ref.repo}/manifests/${ref.tag}`, { + method: 'HEAD', + headers: { accept, ...(token ? { authorization: `Bearer ${token}` } : {}) }, + signal: ctrl.signal, + }); + if (!res.ok) return null; + return res.headers.get('docker-content-digest'); + } finally { + clearTimeout(t); + } + } catch { + return null; + } +} + // 创建 exec 实例。容器 init 未完成时,linuxserver 基镜像的 'abc' 用户可能还没建好,docker 会以 // 400「unable to find user abc: no matching entries in passwd file」直接拒绝创建 exec(见 issue #74)。 // 对这种"用户未就绪"错误短暂重试,给容器 init 一点时间;超时则抛清晰的中文错误,而非透传难懂的 docker 400。 @@ -600,8 +720,20 @@ export async function wechatStatus(inst: Instance): Promise { } } -// 拉取微信镜像(首次部署/更新镜像用)。返回拉取日志的最后状态。 -export async function pullImage(onProgress?: (line: any) => void): Promise { +// 拉取微信镜像(首次部署/更新镜像用)。 +// 并发合并:创建实例/单实例升级/一键升级可能同时触发拉取,同一时刻只跑一个(后来者共享结果; +// 其 onProgress 不再接收进度,可接受——进度只影响创建向导的百分比显示)。 +let pullInFlight: Promise | null = null; +export function pullImage(onProgress?: (line: any) => void): Promise { + if (pullInFlight) return pullInFlight; + pullInFlight = doPullImage(onProgress).finally(() => { + pullInFlight = null; + invalidateRemoteImageCache(); // 本地镜像可能已更新 → 远端新版检测缓存作废 + }); + return pullInFlight; +} + +async function doPullImage(onProgress?: (line: any) => void): Promise { // 无进度超时:NAS 直连 docker.io 常卡死(拉取流僵住、永不结束),旧版会让"创建实例"请求无限 hang, // 前端一直转圈、还删不掉(issue #99)。这里只要 N 分钟内没有任何进度就中止拉取,让创建带清晰错误快速失败、 // 用户可重试/删除。默认 5 分钟,WOC_PULL_STALL_MIN 可调。 diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 9ad672d..266c9cf 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -47,6 +47,7 @@ import { instanceOutdated, pullImage, pruneDanglingImages, + remoteInstanceImageNewer, removeInstance as removeInstanceContainer, instanceRuntime, triggerWechat, @@ -638,21 +639,32 @@ app.post('/api/admin/instances/:id/restart', async (req, reply) => { } }); -// 升级实例(仅管理员):拉取最新微信镜像后重建(保留数据卷)。用于把旧实例更新到新版镜像 -// (如修复"最小化丢失"等),类似「更新微信」但更新的是实例容器镜像本身。 +// 升级实例(仅管理员):拉取最新微信镜像后重建(保留数据卷)。 +// 异步化:拉取在受限网络下可达数分钟(要等到停滞超时),同步等待会被反代在 ~60s 掐断—— +// 前端误报「升级失败」而后台其实还在跑,用户再点一次就撞出并发重建。改为:登记 → 立即返回, +// 前端轮询 upgrade-status 的 upgradingIds 直到该实例移出。 +const upgradingIds = new Set(); app.post('/api/admin/instances/:id/upgrade', async (req, reply) => { if (!requireAdmin(req, reply)) return; const inst = findInstance((req.params as any).id); if (!inst) return reply.code(404).send({ error: '实例不存在' }); - try { - appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}):拉取最新镜像后重建`); - await upgradeInstance(inst); - appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}) 完成`); - return { ok: true }; - } catch (e: any) { - appendPanelLog('ERROR', `升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); - return reply.code(500).send({ error: '升级失败:' + (e?.message || e) }); - } + if (upgradeAllState.running) return reply.code(409).send({ error: '「一键升级全部实例」进行中,请等它完成' }); + if (upgradingIds.has(inst.id)) return reply.code(409).send({ error: '该实例已在升级中' }); + upgradingIds.add(inst.id); + void (async () => { + try { + appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}):拉取最新镜像后重建`); + await upgradeInstance(inst); + appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}) 完成`); + } catch (e: any) { + appendPanelLog('ERROR', `升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); + } finally { + upgradingIds.delete(inst.id); + // 没有其他升级在跑时顺手回收悬空旧镜像 + if (!upgradingIds.size && !upgradeAllState.running) void pruneDanglingImages(); + } + })(); + return { ok: true, started: true }; }); // 实例镜像升级状态:哪些实例的镜像落后于本地最新镜像(用于面板"实例可升级"红点 + 一键升级)。 @@ -670,50 +682,70 @@ app.get('/api/admin/instances/upgrade-status', async (req, reply) => { outdatedCount: outdated.length, outdatedIds: outdated.map((r) => r.id), instances: results, + // 远端 registry 是否有比本地更新的实例镜像(null=未知/离线)。补 instanceOutdated 的盲区: + // 用户更新面板后本地实例镜像还是旧的,仅比本地会误报"无可升级"。 + remoteNewer: remoteInstanceImageNewer(), upgradeAll: upgradeAllState, // 一键升级进行中的进度(running=false 表示空闲/已完成) + upgradingIds: [...upgradingIds], // 单实例升级中的实例(前端轮询用) }; }); // 一键升级全部"镜像落后"的实例。 // 异步化:拉镜像 + 逐个重建可能耗时数分钟到更久(受限网络下拉取要等到停滞超时),同步等待会让 // 前端请求悬死、代理超时——用户反馈"一键升级一直卡死"。改为:立即返回,后台顺序执行, -// 前端轮询 upgrade-status 里的 upgradeAll 进度。镜像只拉一次(不再每实例各拉一次)。 +// 前端轮询 upgrade-status 里的 upgradeAll 进度。 +// 顺序很关键:先拉镜像、再判定谁落后。反过来会把"本来等于旧最新版"的实例漏掉——拉取带来 +// 更新后它们才变落后,用户点完"全部升级"却发现横幅还在。 let upgradeAllState = { running: false, total: 0, done: 0, failed: 0, phase: '' }; app.post('/api/admin/instances/upgrade-all', async (req, reply) => { if (!requireAdmin(req, reply)) return; if (upgradeAllState.running) return reply.code(409).send({ error: '一键升级正在进行中,请等待完成' }); - const latestId = await latestInstanceImageId(); - if (!latestId) return reply.code(409).send({ error: '本地尚无实例镜像,无法判断是否需要升级(请先联网拉取)' }); - const list = listInstances(); - const outdated: typeof list = []; - for (const inst of list) if (await instanceOutdated(inst, latestId)) outdated.push(inst); - if (!outdated.length) return { ok: true, started: false, upgraded: 0, failed: 0 }; - - upgradeAllState = { running: true, total: outdated.length, done: 0, failed: 0, phase: '拉取最新镜像…' }; + if (upgradingIds.size) return reply.code(409).send({ error: '有实例正在单独升级,请等它完成' }); + upgradeAllState = { running: true, total: 0, done: 0, failed: 0, phase: '拉取最新实例镜像…' }; void (async () => { - // 统一拉取一次(失败不阻断:用本地已有镜像重建) try { - await pullImage(); - } catch (e: any) { - appendPanelLog('WARN', `一键升级:拉取镜像失败(${e?.message || e}),改用本地镜像重建`); - } - for (const inst of outdated) { - upgradeAllState.phase = `升级「${inst.name}」…`; + // ① 统一拉取一次(失败不阻断:用本地已有镜像重建) try { - appendPanelLog('INFO', `一键升级实例「${inst.name}」(id=${inst.id})…`); - await upgradeInstance(inst, { skipPull: true }); + await pullImage(); } catch (e: any) { - upgradeAllState.failed++; - appendPanelLog('ERROR', `一键升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); + appendPanelLog('WARN', `一键升级:拉取镜像失败(${e?.message || e}),改用本地镜像重建`); } - upgradeAllState.done++; + // ② 拉取后再判定落后清单 + const latestId = await latestInstanceImageId(); + if (!latestId) { + appendPanelLog('ERROR', '一键升级:本地尚无实例镜像且拉取失败,无法继续'); + return; + } + const outdated: ReturnType = []; + for (const inst of listInstances()) if (await instanceOutdated(inst, latestId)) outdated.push(inst); + upgradeAllState.total = outdated.length; + if (!outdated.length) { + appendPanelLog('INFO', '一键升级:所有实例已是最新镜像'); + return; + } + // ③ 逐个重建(跳过重复拉取) + for (const inst of outdated) { + upgradeAllState.phase = `升级「${inst.name}」…`; + upgradingIds.add(inst.id); + try { + appendPanelLog('INFO', `一键升级实例「${inst.name}」(id=${inst.id})…`); + await upgradeInstance(inst, { skipPull: true }); + } catch (e: any) { + upgradeAllState.failed++; + appendPanelLog('ERROR', `一键升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); + } finally { + upgradingIds.delete(inst.id); + } + upgradeAllState.done++; + } + appendPanelLog('INFO', `一键升级全部实例完成:成功 ${upgradeAllState.done - upgradeAllState.failed}、失败 ${upgradeAllState.failed}`); + // ④ 升级后旧镜像变悬空(),顺手清理防磁盘堆积 + await pruneDanglingImages(); + } finally { + upgradeAllState = { ...upgradeAllState, running: false, phase: '' }; } - appendPanelLog('INFO', `一键升级全部实例完成:成功 ${upgradeAllState.done - upgradeAllState.failed}、失败 ${upgradeAllState.failed}`); - // 升级后旧镜像变悬空(),顺手清理防磁盘堆积 - await pruneDanglingImages(); - upgradeAllState = { ...upgradeAllState, running: false, phase: '' }; })(); - return { ok: true, started: true, total: outdated.length }; + return { ok: true, started: true }; }); // 实例侧:设置该实例可被哪些账户访问 diff --git a/panel/server/src/self-update.ts b/panel/server/src/self-update.ts index 660f274..86ffbe0 100644 --- a/panel/server/src/self-update.ts +++ b/panel/server/src/self-update.ts @@ -13,16 +13,44 @@ import Docker from 'dockerode'; import { appendPanelLog } from './logs.js'; +import { versionInfo } from './version.js'; const docker = new Docker(); const PANEL_NAME = process.env.WOC_PANEL_CONTAINER || 'woc-panel'; const UPDATER_NAME = PANEL_NAME + '-updater'; +// 拉取(带无进度停滞超时):NAS 受限网络下拉取流可能僵住永不结束,同步等待会让 +// 「一键更新面板」无限卡住且 updateInFlight 永远不复位。与实例镜像 pullImage 同款保护。 function pull(ref: string): Promise { + const STALL_MS = 1000 * 60 * Math.max(2, Number(process.env.WOC_PULL_STALL_MIN) || 5); return new Promise((resolve, reject) => { docker.pull(ref, (err: any, stream: NodeJS.ReadableStream) => { if (err) return reject(err); - docker.modem.followProgress(stream, (e: any) => (e ? reject(e) : resolve())); + let done = false; + let timer: ReturnType; + const finish = (e: any) => { + if (done) return; + done = true; + clearTimeout(timer); + e ? reject(e) : resolve(); + }; + const arm = () => { + clearTimeout(timer); + timer = setTimeout(() => { + try { + (stream as any).destroy?.(); + } catch { + /* ignore */ + } + finish(new Error(`拉取 ${ref} ${Math.round(STALL_MS / 60000)} 分钟无进度,判定网络卡死并中止`)); + }, STALL_MS); + }; + arm(); + docker.modem.followProgress( + stream, + (e: any) => finish(e), + () => arm(), // 每有进度就重置超时 + ); }); }); } @@ -85,6 +113,11 @@ let updateInFlight = false; export async function triggerSelfUpdate(): Promise<{ target: string }> { if (updateInFlight) throw new Error('面板更新已在进行中,请稍候'); updateInFlight = true; + // 兜底复位:helper 派生成功但静默失败(如 sock 权限问题)时面板存活、本标志却永远为 true, + // 用户从此无法重试。10 分钟后自动放开(正常成功路径面板早被重建,本定时器无意义)。 + setTimeout(() => { + updateInFlight = false; + }, 10 * 60 * 1000).unref(); try { return await doSelfUpdate(); } catch (e) { @@ -97,9 +130,25 @@ async function doSelfUpdate(): Promise<{ target: string }> { const self: any = await docker.getContainer(PANEL_NAME).inspect(); const ref: string = self.Config.Image; // 如 docker.io/gloridust/woc-panel:latest 或 :v1.2.1 const repo = ref.split('@')[0].replace(/:[^/:]+$/, ''); // 去 tag - const target = `${repo}:latest`; // 拉最新发布 - appendPanelLog('WARN', `面板自更新:开始拉取 ${target}`); - await pull(target); + // 版本锚定(架构守则 R1):优先拉「更新检查」宣告的那个具体版本(CI 打的裸语义化 tag,如 1.3.1), + // 保证用户点「更新到 v1.3.1」拿到的就是 v1.3.1,而不是拉取瞬间恰好指到别处的 :latest; + // 该 tag 不存在(老版本未打 tag / 私有源)再回退 :latest。 + const latestV = versionInfo().latest; // 'v1.3.1' | null + const candidates = latestV ? [`${repo}:${latestV.replace(/^v/, '')}`, `${repo}:latest`] : [`${repo}:latest`]; + let target = ''; + let lastErr: any = null; + for (const cand of candidates) { + appendPanelLog('WARN', `面板自更新:开始拉取 ${cand}`); + try { + await pull(cand); + target = cand; + break; + } catch (e: any) { + lastErr = e; + appendPanelLog('WARN', `拉取 ${cand} 失败:${e?.message || e}${cand === candidates[candidates.length - 1] ? '' : ',尝试回退 tag'}`); + } + } + if (!target) throw lastErr || new Error('拉取面板镜像失败'); appendPanelLog('INFO', `面板自更新:${target} 已拉取,派生 ${UPDATER_NAME} 容器重建面板(数据保留)`); const spec = { panelName: PANEL_NAME, newImage: target, oldImageId: self.Image }; diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index 0780678..7d0ed41 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -201,18 +201,22 @@ export const api = { instanceStart: (id: string) => req(`/api/admin/instances/${id}/start`, { method: 'POST' }), instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }), instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }), - instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }), - // 实例镜像升级状态(哪些实例落后于最新镜像 + 一键升级进行中的进度)+ 发起一键升级(异步,立即返回,轮询进度)。 + // 单实例升级:异步(后端登记后立即返回),轮询 upgradeStatus().upgradingIds 直到该 id 移出。 + instanceUpgrade: (id: string) => req<{ ok: boolean; started: boolean }>(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }), + // 实例镜像升级状态(哪些实例落后于本地最新镜像、远端是否有新版、单个/批量升级进度)。 upgradeStatus: () => req<{ known: boolean; outdatedCount: number; outdatedIds: string[]; instances: { id: string; name: string; outdated: boolean }[]; + remoteNewer: boolean | null; upgradeAll: { running: boolean; total: number; done: number; failed: number; phase: string }; + upgradingIds: string[]; }>('/api/admin/instances/upgrade-status'), + // 一键升级全部(异步,立即返回;先拉镜像再判定落后,进度看 upgradeStatus().upgradeAll)。 upgradeAllInstances: () => - req<{ ok: boolean; started: boolean; total?: number }>('/api/admin/instances/upgrade-all', { method: 'POST' }), + req<{ ok: boolean; started: boolean }>('/api/admin/instances/upgrade-all', { method: 'POST' }), instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`, // 全局日志 / 诊断包(范围 24h/7d/30d/1y) diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`, diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index aa71b2b..599f6b3 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -139,10 +139,18 @@ function AboutSection({ isAdmin }: { isAdmin: boolean }) { const [checking, setChecking] = useState(false); const [updating, setUpdating] = useState(false); const [outdatedInst, setOutdatedInst] = useState(0); // 镜像落后的实例数(提示"更新面板≠更新实例") + const [remoteNewer, setRemoteNewer] = useState(false); // 远端有新实例镜像(本地还没拉) useEffect(() => { api.getVersion().then(setInfo).catch(() => {}); - if (isAdmin) api.upgradeStatus().then((s) => setOutdatedInst(s.outdatedCount)).catch(() => {}); + if (isAdmin) + api + .upgradeStatus() + .then((s) => { + setOutdatedInst(s.outdatedCount); + setRemoteNewer(s.remoteNewer === true); + }) + .catch(() => {}); }, [isAdmin]); // 一键更新面板:拉新镜像 + 派生 helper 容器重建 woc-panel(数据保留,带失败回滚)。 @@ -211,9 +219,10 @@ function AboutSection({ isAdmin }: { isAdmin: boolean }) { : '点「一键更新面板」即可自动拉新镜像并重建面板(数据/登录保留,约十几秒、期间会短暂重启,完成后自动刷新)。各实例镜像可在「管理 → 升级」单独更新。'} )} - {isAdmin && outdatedInst > 0 && ( + {isAdmin && (outdatedInst > 0 || remoteNewer) && (
- ⚠️ 另有 {outdatedInst} 个实例的镜像可升级。更新面板不会自动升级实例(二者是不同镜像)——请到「管理」用「一键升级全部实例」。 + ⚠️ {outdatedInst > 0 ? <>另有 {outdatedInst} 个实例的镜像可升级。 : <>实例镜像检测到新版本。} + 更新面板不会自动升级实例(二者是不同镜像)——请到「管理」用「一键升级全部实例」。
)}
@@ -268,9 +277,10 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () const [volumeInst, setVolumeInst] = useState(null); // 数据卷管理弹窗 const [iconInst, setIconInst] = useState(null); // 图标编辑弹窗 const [acting, setActing] = useState>({}); // 实例 id → 进行中的动作文案(启动中/升级中…) - const [upg, setUpg] = useState<{ outdatedCount: number; outdatedIds: string[] } | null>(null); // 镜像落后的实例 + const [upg, setUpg] = useState<{ outdatedCount: number; outdatedIds: string[]; remoteNewer: boolean } | null>(null); // 镜像落后的实例 + 远端有新版 const [upgradingAll, setUpgradingAll] = useState(false); const [upgProgress, setUpgProgress] = useState(''); // 一键升级进度文案("2/5 · 升级「xxx」…") + const pollingRef = useRef(false); // 防止 load() 恢复轮询与手动发起的轮询并存 // 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。 const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]); // 残留 woc-wx-* 容器(runInstance 启动失败遗留的 Created 容器等):占着卷名让删卷报 409。 @@ -304,7 +314,9 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () } try { const s = await api.upgradeStatus(); - setUpg({ outdatedCount: s.outdatedCount, outdatedIds: s.outdatedIds }); + setUpg({ outdatedCount: s.outdatedCount, outdatedIds: s.outdatedIds, remoteNewer: s.remoteNewer === true }); + // 刷新页面/重进管理页时发现后台一键升级还在跑 → 恢复进度条与轮询 + if (s.upgradeAll.running && !pollingRef.current) void pollUpgradeAll(); } catch { /* ignore:更新检测失败不影响管理页 */ } @@ -401,10 +413,30 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () const lifecycle = async (inst: InstanceWithStatus, kind: 'stop' | 'restart' | 'upgrade') => { const label = kind === 'stop' ? '停止中…' : kind === 'upgrade' ? '升级中…' : '重启中…'; setAct(inst.id, label); - if (kind === 'upgrade') toast('正在升级实例:拉取最新镜像并重建,可能需要几分钟,请勿离开…', 'info'); try { - await (kind === 'stop' ? api.instanceStop(inst.id) : kind === 'upgrade' ? api.instanceUpgrade(inst.id) : api.instanceRestart(inst.id)); - toast(kind === 'stop' ? '已停止' : kind === 'upgrade' ? '已升级到最新镜像并重启' : '已重启', 'ok'); + if (kind === 'upgrade') { + // 升级是后端异步任务(拉镜像可能数分钟):发起后轮询 upgradingIds 直到完成, + // 避免同步等待被反代掐断而误报失败(旧版实况)。 + await api.instanceUpgrade(inst.id); + toast('已开始升级:拉取最新镜像并重建(后台进行)…', 'info'); + for (let i = 0; i < 400; i++) { + await new Promise((res) => setTimeout(res, 3000)); + try { + const s = await api.upgradeStatus(); + if (!s.upgradingIds.includes(inst.id)) { + // 完成后据"是否仍落后"给结论(失败详情在面板日志) + if (s.outdatedIds.includes(inst.id)) toast('升级未完成,请查看「面板日志」', 'error'); + else toast('已升级到最新镜像并重启', 'ok'); + break; + } + } catch { + /* 面板短暂不可达,继续轮询 */ + } + } + } else { + await (kind === 'stop' ? api.instanceStop(inst.id) : api.instanceRestart(inst.id)); + toast(kind === 'stop' ? '已停止' : '已重启', 'ok'); + } await load(); } catch (e: any) { toast(e.message || '操作失败', 'error'); @@ -413,49 +445,59 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () } }; - // 一键升级全部"镜像落后"的实例。后端异步执行(拉镜像+逐个重建可能数分钟), - // 这里发起后轮询 upgrade-status 里的进度,避免单个请求悬死(旧版同步等待被反馈"一直卡死")。 - const upgradeAll = async () => { - const n = upg?.outdatedCount || 0; - const ok = await confirm({ - title: `升级全部 ${n} 个可升级实例?`, - body: '后台逐个拉取最新实例镜像并重建(数据保留);期间这些实例会短暂重连,可离开本页。', - confirmText: '全部升级', - }); - if (!ok) return; + // 轮询一键升级进度直到完成(3s 一次;异常网络下最多轮 30 分钟兜底退出)。 + // 发起升级与"刷新页面后发现后台还在跑"(load 里检测)都走这里。 + const pollUpgradeAll = async () => { + if (pollingRef.current) return; + pollingRef.current = true; setUpgradingAll(true); try { - const r = await api.upgradeAllInstances(); - if (!r.started) { - toast('所有实例已是最新镜像', 'ok'); - setUpgradingAll(false); - await load(); - return; - } - toast(`已开始升级 ${r.total} 个实例(后台进行)…`, 'info'); - // 轮询进度直到完成(3s 一次;异常网络下最多轮 30 分钟兜底退出) for (let i = 0; i < 600; i++) { await new Promise((res) => setTimeout(res, 3000)); try { const s = await api.upgradeStatus(); const p = s.upgradeAll; if (p.running) { - setUpgProgress(`${p.done}/${p.total}${p.phase ? ` · ${p.phase}` : ''}`); + // 拉取阶段 total=0,只显示 phase;进入逐个升级后显示 n/total + setUpgProgress(p.total ? `${p.done}/${p.total}${p.phase ? ` · ${p.phase}` : ''}` : p.phase || '…'); continue; } - toast( - `升级完成:成功 ${p.total - p.failed}${p.failed ? `、失败 ${p.failed}(看面板日志)` : ''}`, - p.failed ? 'error' : 'ok', - ); + if (p.total === 0) toast('所有实例已是最新镜像', 'ok'); + else + toast( + `升级完成:成功 ${p.total - p.failed}${p.failed ? `、失败 ${p.failed}(看面板日志)` : ''}`, + p.failed ? 'error' : 'ok', + ); break; } catch { /* 面板短暂不可达(不影响后台任务),继续轮询 */ } } await load(); + } finally { + pollingRef.current = false; + setUpgradingAll(false); + setUpgProgress(''); + } + }; + + // 一键升级全部"镜像落后"的实例。后端异步执行(先统一拉镜像、再逐个重建,可能数分钟), + // 这里发起后轮询 upgrade-status 里的进度,避免单个请求悬死(旧版同步等待被反馈"一直卡死")。 + const upgradeAll = async () => { + const n = upg?.outdatedCount || 0; + const ok = await confirm({ + title: n ? `升级全部 ${n} 个可升级实例?` : '拉取新版镜像并升级全部实例?', + body: '后台先拉取最新实例镜像,再逐个重建(数据保留);期间这些实例会短暂重连,可离开本页。', + confirmText: '全部升级', + }); + if (!ok) return; + setUpgradingAll(true); + try { + await api.upgradeAllInstances(); + toast('已开始升级(后台进行,可离开本页)…', 'info'); + await pollUpgradeAll(); } catch (e: any) { toast(e.message || '升级失败', 'error'); - } finally { setUpgradingAll(false); setUpgProgress(''); } @@ -505,10 +547,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () + 新建实例
- {!!upg?.outdatedCount && ( + {!!(upg?.outdatedCount || upg?.remoteNewer) && instances.length > 0 && (
- 有 {upg.outdatedCount} 个实例的镜像可升级到最新版。 + {upg.outdatedCount ? ( + <> + 有 {upg.outdatedCount} 个实例的镜像可升级到最新版。 + + ) : ( + <>检测到实例镜像有新版本可拉取。 + )} (更新面板不会自动升级实例,二者是不同镜像)