fix(update): 更新机制专项审查 —— 远端新版检测盲区 + 单实例升级异步化 + 自更新加固

对面板自更新/单实例升级/一键升级三条链路审查后逐项修复:

盲区(最重要):实例"可升级"检测只比「容器镜像 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) <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-07-06 23:56:03 +08:00
parent 7ec79314bb
commit 4a6d71844b
5 changed files with 346 additions and 81 deletions

View File

@@ -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 变成 <none>,长期堆积吃磁盘
@@ -512,6 +522,116 @@ export async function instanceOutdated(inst: Instance, latestId: string | null):
}
}
// ---------- 远端实例镜像新版检测 ----------
// 盲区背景instanceOutdated 只比「容器镜像 vs 本地镜像」。用户更新面板后,本地实例镜像
// 往往还是旧的(没人主动 pull→ 检测恒为"无可升级"→ 升级引导永远不出现。这里用 registry
// manifest digestHEAD 请求,不下载)对比本地镜像的 RepoDigests判断远端是否有新版。
// best-effort离线/被墙/私有源 → null未知不打扰本地自构建镜像无 RepoDigests→ null。
let remoteImageCache: { val: boolean | null; at: number } = { val: null, at: 0 };
let remoteImageInflight: Promise<void> | 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<boolean | null> {
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<string, string>, ms = 8000): Promise<any> {
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<string | null> {
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<WechatStatus> {
}
}
// 拉取微信镜像(首次部署/更新镜像用)。返回拉取日志的最后状态。
export async function pullImage(onProgress?: (line: any) => void): Promise<void> {
// 拉取微信镜像(首次部署/更新镜像用)。
// 并发合并:创建实例/单实例升级/一键升级可能同时触发拉取,同一时刻只跑一个(后来者共享结果;
// 其 onProgress 不再接收进度,可接受——进度只影响创建向导的百分比显示)。
let pullInFlight: Promise<void> | null = null;
export function pullImage(onProgress?: (line: any) => void): Promise<void> {
if (pullInFlight) return pullInFlight;
pullInFlight = doPullImage(onProgress).finally(() => {
pullInFlight = null;
invalidateRemoteImageCache(); // 本地镜像可能已更新 → 远端新版检测缓存作废
});
return pullInFlight;
}
async function doPullImage(onProgress?: (line: any) => void): Promise<void> {
// 无进度超时NAS 直连 docker.io 常卡死(拉取流僵住、永不结束),旧版会让"创建实例"请求无限 hang
// 前端一直转圈、还删不掉issue #99。这里只要 N 分钟内没有任何进度就中止拉取,让创建带清晰错误快速失败、
// 用户可重试/删除。默认 5 分钟WOC_PULL_STALL_MIN 可调。

View File

@@ -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<string>();
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<typeof listInstances> = [];
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}`);
// ④ 升级后旧镜像变悬空(<none>),顺手清理防磁盘堆积
await pruneDanglingImages();
} finally {
upgradeAllState = { ...upgradeAllState, running: false, phase: '' };
}
appendPanelLog('INFO', `一键升级全部实例完成:成功 ${upgradeAllState.done - upgradeAllState.failed}、失败 ${upgradeAllState.failed}`);
// 升级后旧镜像变悬空(<none>),顺手清理防磁盘堆积
await pruneDanglingImages();
upgradeAllState = { ...upgradeAllState, running: false, phase: '' };
})();
return { ok: true, started: true, total: outdated.length };
return { ok: true, started: true };
});
// 实例侧:设置该实例可被哪些账户访问

View File

@@ -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<void> {
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<typeof setTimeout>;
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 };

View File

@@ -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)}`,

View File

@@ -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 }) {
: '点「一键更新面板」即可自动拉新镜像并重建面板(数据/登录保留,约十几秒、期间会短暂重启,完成后自动刷新)。各实例镜像可在「管理 → 升级」单独更新。'}
</div>
)}
{isAdmin && outdatedInst > 0 && (
{isAdmin && (outdatedInst > 0 || remoteNewer) && (
<div className="ver-hint">
<b>{outdatedInst}</b> <b></b>
{outdatedInst > 0 ? <> <b>{outdatedInst}</b> </> : <></>}
<b></b>
</div>
)}
<div className="settings-actions">
@@ -268,9 +277,10 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
const [volumeInst, setVolumeInst] = useState<InstanceWithStatus | null>(null); // 数据卷管理弹窗
const [iconInst, setIconInst] = useState<InstanceWithStatus | null>(null); // 图标编辑弹窗
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 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: ()
+
</button>
</div>
{!!upg?.outdatedCount && (
{!!(upg?.outdatedCount || upg?.remoteNewer) && instances.length > 0 && (
<div className="upgrade-banner">
<span>
<b>{upg.outdatedCount}</b>
{upg.outdatedCount ? (
<>
<b>{upg.outdatedCount}</b>
</>
) : (
<></>
)}
<span className="muted small"></span>
</span>
<button className="btn btn-primary s-btn" disabled={upgradingAll} onClick={upgradeAll}>