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:
Gloridust
2026-07-07 01:01:22 +08:00
parent 4d9cc1015a
commit d523204c6b
5 changed files with 74 additions and 2 deletions

View File

@@ -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

View File

@@ -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 };

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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 {