mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-09-20 03:23:32 +08:00
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:
@@ -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 digest(HEAD 请求,不下载)对比本地镜像的 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 可调。
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
// 实例侧:设置该实例可被哪些账户访问
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user