mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-08-30 18:21:27 -07:00
feat(#112): 实例卡显示镜像版本 + 升级结果诚实汇报(拉取失败不再假装成功)
issue #112 复盘出两个产品缺陷(用户"以为升级了其实没有"、且无从自查): - 实例卡新增「镜像 vX.Y.Z」角标(读 CI 的 org.opencontainers.image.version label; 自构建镜像显示短 id),用户一眼可见每个实例到底跑的哪版。 - upgradeInstance 记录升级前后镜像 id:拉取失败且镜像未变 → 抛错按失败处理并说明原因 (原先静默回退本地旧镜像还报"完成");镜像更换/已最新也分别写入实例日志。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -321,13 +321,25 @@ export async function ensureRunning(inst: Instance): Promise<void> {
|
||||
// skipPull:批量升级时由调用方先统一拉取一次,避免 N 个实例拉 N 次(受限网络下每次
|
||||
// 都要等到拉取停滞超时,表现为"一键升级卡死")。
|
||||
export async function upgradeInstance(inst: Instance, opts?: { skipPull?: boolean }): Promise<void> {
|
||||
let pullErr: any = null;
|
||||
if (!opts?.skipPull) {
|
||||
try {
|
||||
await pullImage();
|
||||
} catch (e: any) {
|
||||
pullErr = e;
|
||||
console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e);
|
||||
}
|
||||
}
|
||||
// 记录升级前镜像,用于事后判断"升级是否真的换了镜像"(issue #112:拉取失败静默回退旧镜像,
|
||||
// 用户被告知"完成"、实际什么都没变,且无从自查)。
|
||||
const before = await (async () => {
|
||||
try {
|
||||
const info: any = await docker.getContainer(inst.containerName).inspect();
|
||||
return String(info.Image || '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
// 升级不改变用户的运行状态:原本停止的实例,升级(重建)后停回去,而不是悄悄拉起。
|
||||
const wasStopped = (await instanceRuntime(inst)) === 'stopped';
|
||||
await runInstance(inst);
|
||||
@@ -339,6 +351,26 @@ export async function upgradeInstance(inst: Instance, opts?: { skipPull?: boolea
|
||||
/* 停不回去也不算失败 */
|
||||
}
|
||||
}
|
||||
const after = await (async () => {
|
||||
try {
|
||||
const info: any = await docker.getContainer(inst.containerName).inspect();
|
||||
return String(info.Image || '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
if (pullErr && before && after === before) {
|
||||
// 诚实汇报:拉取失败且镜像未变 = 这次"升级"没有升级任何东西。抛错让调用方按失败处理,
|
||||
// 用户才知道要去解决网络/镜像源问题,而不是误以为已修复。
|
||||
throw new Error(
|
||||
`拉取新镜像失败(${pullErr?.message || pullErr}),实例仍在原镜像上重建(未升级)。请检查网络/镜像源后重试`,
|
||||
);
|
||||
}
|
||||
if (before && after !== before) {
|
||||
appendInstanceLog(inst.id, `镜像已更换:${before.slice(7, 19)} → ${after.slice(7, 19)}`);
|
||||
} else if (!pullErr) {
|
||||
appendInstanceLog(inst.id, '镜像已是最新,无需更换');
|
||||
}
|
||||
}
|
||||
|
||||
// 清理悬空(dangling)镜像:升级后旧实例镜像失去 tag 变成 <none>,长期堆积吃磁盘
|
||||
@@ -544,6 +576,27 @@ export async function instanceOutdated(inst: Instance, latestId: string | null):
|
||||
}
|
||||
}
|
||||
|
||||
// 实例容器当前运行镜像的版本号(CI 打的 org.opencontainers.image.version label,如 "1.4.0")。
|
||||
// 本地自构建镜像无该 label → 返回镜像短 id(显示为"自构建 xxxx"级别信息);容器不存在 → null。
|
||||
// 背景(issue #112):用户无从得知实例到底跑的哪版镜像,"以为升级了其实没有"无法自查。
|
||||
export async function instanceImageVersion(inst: Instance): Promise<string | null> {
|
||||
try {
|
||||
const info: any = await docker.getContainer(inst.containerName).inspect();
|
||||
const imgId = String(info.Image || '');
|
||||
if (!imgId) return null;
|
||||
try {
|
||||
const img: any = await docker.getImage(imgId).inspect();
|
||||
const v = img?.Config?.Labels?.['org.opencontainers.image.version'];
|
||||
if (v) return String(v);
|
||||
} catch {
|
||||
/* 镜像记录不可读(containerd 快照残留),退回短 id */
|
||||
}
|
||||
return imgId.replace(/^sha256:/, '').slice(0, 12);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 远端实例镜像新版检测 ----------
|
||||
// 盲区背景:instanceOutdated 只比「容器镜像 vs 本地镜像」。用户更新面板后,本地实例镜像
|
||||
// 往往还是旧的(没人主动 pull)→ 检测恒为"无可升级"→ 升级引导永远不出现。这里用 registry
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
remoteInstanceImageNewer,
|
||||
removeInstance as removeInstanceContainer,
|
||||
instanceRuntime,
|
||||
instanceImageVersion,
|
||||
triggerWechat,
|
||||
wechatStatus,
|
||||
instanceTarget,
|
||||
@@ -348,8 +349,12 @@ app.get('/api/instances', async (req, reply) => {
|
||||
const out = await Promise.all(
|
||||
visible.map(async (pub) => {
|
||||
const inst = findInstance(pub.id)!;
|
||||
const [runtime, wx] = await Promise.all([instanceRuntime(inst), wechatStatus(inst)]);
|
||||
return { ...pub, runtime, wechat: wx };
|
||||
const [runtime, wx, imageVersion] = await Promise.all([
|
||||
instanceRuntime(inst),
|
||||
wechatStatus(inst),
|
||||
instanceImageVersion(inst), // 实例镜像版本(CI label;自构建为短 id)——让用户能自查"到底跑的哪版"
|
||||
]);
|
||||
return { ...pub, runtime, wechat: wx, imageVersion };
|
||||
}),
|
||||
);
|
||||
return { instances: out };
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface MemLimits {
|
||||
export interface InstanceWithStatus extends PanelInstance {
|
||||
runtime: RuntimeState;
|
||||
wechat: WechatStatus;
|
||||
imageVersion?: string | null; // 实例镜像版本(CI 发布版如 "1.4.0";自构建为镜像短 id;容器缺失为 null)
|
||||
}
|
||||
|
||||
export interface VolEntry {
|
||||
|
||||
@@ -1285,6 +1285,14 @@ function InstanceAdminCard({
|
||||
可升级
|
||||
</span>
|
||||
)}
|
||||
{inst.imageVersion && (
|
||||
<span
|
||||
className="tag tag-muted"
|
||||
title={/^\d+\.\d+\.\d+$/.test(inst.imageVersion) ? '该实例当前运行的镜像版本' : '本地自构建镜像(无发布版本号,显示镜像短 id)'}
|
||||
>
|
||||
镜像 {/^\d+\.\d+\.\d+$/.test(inst.imageVersion) ? `v${inst.imageVersion}` : inst.imageVersion.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="inst-sub">
|
||||
{sub}
|
||||
|
||||
@@ -1087,6 +1087,11 @@ button {
|
||||
background: rgba(245 158 11 / 0.18);
|
||||
color: #b9770a;
|
||||
}
|
||||
.tag-muted {
|
||||
background: rgba(128 138 157 / 0.14);
|
||||
color: var(--muted, #6b7280);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------- chip 多选 / 展示 ---------- */
|
||||
.field-label {
|
||||
|
||||
Reference in New Issue
Block a user