feat(update): 实例镜像落后检测 + 一键升级全部 + 壁纸缺工具友好报错

面板与实例是两套镜像:更新面板不会动实例,用户常"更新了面板、实例还旧"(如设壁纸报 127)。补齐:
- 后端:latestInstanceImageId + instanceOutdated(运行镜像 != 本地最新即落后);
  GET /instances/upgrade-status(落后清单)、POST /instances/upgrade-all(逐个升级落后实例)。
- 前端:管理页顶部横幅「有 N 个实例可升级 + 一键升级全部实例」,每张实例卡加「可升级」角标;
  关于页面板更新后也提示"另有 N 个实例镜像可升级"(更新面板≠更新实例)。
- 壁纸:applyBackground 前置检查 xwallpaper,旧镜像缺失时提示"请先在管理升级实例"而非退出码 127。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-07-05 01:15:30 +08:00
parent e880f9d2fc
commit db894cfcff
5 changed files with 155 additions and 1 deletions

View File

@@ -470,6 +470,29 @@ export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
}
}
// 本地「最新实例镜像」的 Id新建/升级实例会用到的镜像)。查不到(未拉取过)返回 null。
export async function latestInstanceImageId(): Promise<string | null> {
try {
const img: any = await docker.getImage(WECHAT_IMAGE).inspect();
return String(img.Id);
} catch {
return null;
}
}
// 实例是否「镜像落后」:其运行中容器的镜像 Id 与本地最新镜像不一致(即重建就会换新镜像)。
// 容器不存在 / 查不到最新镜像时返回 false不打扰。传入 latestId 复用一次查询,避免 N 次 inspect。
export async function instanceOutdated(inst: Instance, latestId: string | null): Promise<boolean> {
if (!latestId) return false;
try {
const info: any = await docker.getContainer(inst.containerName).inspect();
const cur = String(info.Image || '');
return !!cur && cur !== latestId;
} catch {
return false; // 容器不存在(未创建/已删)→ 不算落后
}
}
// 创建 exec 实例。容器 init 未完成时linuxserver 基镜像的 'abc' 用户可能还没建好docker 会以
// 400「unable to find user abc: no matching entries in passwd file」直接拒绝创建 exec见 issue #74
// 对这种"用户未就绪"错误短暂重试,给容器 init 一点时间;超时则抛清晰的中文错误,而非透传难懂的 docker 400。
@@ -1061,8 +1084,21 @@ export async function uploadBackground(inst: Instance, name: string, content: Bu
await docker.getContainer(inst.containerName).putArchive(tarSingleFile(name, content), { path: BG_DIR });
}
// 检查实例容器内是否有某命令;没有则抛出友好错误(多为旧镜像未升级,避免用户看懵"退出码 127")。
async function assertHasTool(inst: Instance, tool: string, msg: string): Promise<void> {
let ok = false;
try {
const out = await execCapture(inst, ['sh', '-c', `command -v ${tool} >/dev/null 2>&1 && printf ok`]);
ok = out.trim() === 'ok';
} catch {
ok = false;
}
if (!ok) throw new Error(msg);
}
export async function applyBackground(inst: Instance, name: string): Promise<void> {
if (!safeMediaName(name)) throw new Error('文件名不合法');
await assertHasTool(inst, 'xwallpaper', '该实例镜像过旧(缺壁纸组件 xwallpaper。请先在「管理」对该实例点「升级」再设置壁纸。');
await execCapture(inst, ['sh', '-c', `DISPLAY=:1 xwallpaper --zoom '${BG_DIR}/${name}' 2>/dev/null`]);
await execCapture(inst, ['sh', '-c', `echo '${name}' > '${WP_FILE}'`]);
}

View File

@@ -43,6 +43,8 @@ import {
runInstance,
stopInstance,
upgradeInstance,
latestInstanceImageId,
instanceOutdated,
removeInstance as removeInstanceContainer,
instanceRuntime,
triggerWechat,
@@ -651,6 +653,42 @@ app.post('/api/admin/instances/:id/upgrade', async (req, reply) => {
}
});
// 实例镜像升级状态:哪些实例的镜像落后于本地最新镜像(用于面板"实例可升级"红点 + 一键升级)。
// 面板与实例是两套镜像/容器:更新面板不会动实例,故用户常"更新了面板、实例还是旧镜像"(例:设壁纸报 127
app.get('/api/admin/instances/upgrade-status', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const latestId = await latestInstanceImageId();
const list = listInstances();
const results = await Promise.all(
list.map(async (inst) => ({ id: inst.id, name: inst.name, outdated: await instanceOutdated(inst, latestId) })),
);
const outdated = results.filter((r) => r.outdated);
return { known: !!latestId, outdatedCount: outdated.length, outdatedIds: outdated.map((r) => r.id), instances: results };
});
// 一键升级全部"镜像落后"的实例(顺序执行,逐个 best-effort个别失败不影响其余
app.post('/api/admin/instances/upgrade-all', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const latestId = await latestInstanceImageId();
if (!latestId) return reply.code(409).send({ error: '本地尚无实例镜像,无法判断是否需要升级(请先联网拉取)' });
const list = listInstances();
let upgraded = 0;
let failed = 0;
for (const inst of list) {
if (!(await instanceOutdated(inst, latestId))) continue;
try {
appendPanelLog('INFO', `一键升级实例「${inst.name}」(id=${inst.id})…`);
await upgradeInstance(inst);
upgraded++;
} catch (e: any) {
failed++;
appendPanelLog('ERROR', `一键升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
}
}
appendPanelLog('INFO', `一键升级全部实例完成:成功 ${upgraded}、失败 ${failed}`);
return { ok: true, upgraded, failed };
});
// 实例侧:设置该实例可被哪些账户访问
app.post('/api/admin/instances/:id/users', async (req, reply) => {
if (!requireAdmin(req, reply)) return;

View File

@@ -202,6 +202,13 @@ export const api = {
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: () =>
req<{ known: boolean; outdatedCount: number; outdatedIds: string[]; instances: { id: string; name: string; outdated: boolean }[] }>(
'/api/admin/instances/upgrade-status',
),
upgradeAllInstances: () =>
req<{ ok: boolean; upgraded: number; failed: number }>('/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

@@ -138,10 +138,12 @@ function AboutSection({ isAdmin }: { isAdmin: boolean }) {
const [info, setInfo] = useState<VersionInfo | null>(null);
const [checking, setChecking] = useState(false);
const [updating, setUpdating] = useState(false);
const [outdatedInst, setOutdatedInst] = useState(0); // 镜像落后的实例数(提示"更新面板≠更新实例"
useEffect(() => {
api.getVersion().then(setInfo).catch(() => {});
}, []);
if (isAdmin) api.upgradeStatus().then((s) => setOutdatedInst(s.outdatedCount)).catch(() => {});
}, [isAdmin]);
// 一键更新面板:拉新镜像 + 派生 helper 容器重建 woc-panel数据保留带失败回滚
// 触发后面板会被重建、本连接短暂中断,约 20s 后自动刷新到新版本。
@@ -209,6 +211,11 @@ function AboutSection({ isAdmin }: { isAdmin: boolean }) {
: '点「一键更新面板」即可自动拉新镜像并重建面板(数据/登录保留,约十几秒、期间会短暂重启,完成后自动刷新)。各实例镜像可在「管理 → 升级」单独更新。'}
</div>
)}
{isAdmin && outdatedInst > 0 && (
<div className="ver-hint">
<b>{outdatedInst}</b> <b></b>
</div>
)}
<div className="settings-actions">
{info?.hasUpdate && isAdmin && (
<button className="btn btn-primary s-btn" disabled={updating} onClick={selfUpdate}>
@@ -261,6 +268,8 @@ 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 [upgradingAll, setUpgradingAll] = useState(false);
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
// 残留 woc-wx-* 容器runInstance 启动失败遗留的 Created 容器等):占着卷名让删卷报 409。
@@ -292,6 +301,12 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
} catch {
/* ignore */
}
try {
const s = await api.upgradeStatus();
setUpg({ outdatedCount: s.outdatedCount, outdatedIds: s.outdatedIds });
} catch {
/* ignore更新检测失败不影响管理页 */
}
try {
const { containers } = await api.listOrphanContainers();
setOrphanConts(containers);
@@ -397,6 +412,28 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
}
};
// 一键升级全部"镜像落后"的实例(逐个拉新镜像重建,可能几分钟)。
const upgradeAll = async () => {
const n = upg?.outdatedCount || 0;
const ok = await confirm({
title: `升级全部 ${n} 个可升级实例?`,
body: '将逐个拉取最新实例镜像并重建(数据保留),每个约十几秒到几分钟;期间这些实例会短暂重连。',
confirmText: '全部升级',
});
if (!ok) return;
setUpgradingAll(true);
toast('正在逐个升级实例,请勿离开…', 'info');
try {
const r = await api.upgradeAllInstances();
toast(`升级完成:成功 ${r.upgraded}${r.failed ? `、失败 ${r.failed}` : ''}`, r.failed ? 'error' : 'ok');
await load();
} catch (e: any) {
toast(e.message || '升级失败', 'error');
} finally {
setUpgradingAll(false);
}
};
const instName = (id: string) => instances.find((i) => i.id === id)?.name || id;
const usersForInstance = (id: string) => subs.filter((u) => u.allowedInstances.includes(id));
@@ -441,6 +478,17 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
+
</button>
</div>
{!!upg?.outdatedCount && (
<div className="upgrade-banner">
<span>
<b>{upg.outdatedCount}</b>
<span className="muted small"></span>
</span>
<button className="btn btn-primary s-btn" disabled={upgradingAll} onClick={upgradeAll}>
{upgradingAll ? '升级中…请稍候' : '一键升级全部实例'}
</button>
</div>
)}
{instances.length === 0 ? (
<EmptyState
icon="🖥️"
@@ -458,6 +506,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
<InstanceAdminCard
key={inst.id}
inst={inst}
outdated={!!upg?.outdatedIds.includes(inst.id)}
userCount={usersForInstance(inst.id).length}
acting={acting[inst.id]}
onEnter={() => nav(`/i/${inst.id}`)}
@@ -1083,6 +1132,7 @@ function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; o
// 管理页的实例卡片:含微信版本管理(下载/更新)+ 重命名/分配/删除
function InstanceAdminCard({
inst,
outdated,
userCount,
acting,
onEnter,
@@ -1099,6 +1149,7 @@ function InstanceAdminCard({
onIcon,
}: {
inst: InstanceWithStatus;
outdated?: boolean;
userCount: number;
acting?: string;
onEnter: () => void;
@@ -1153,6 +1204,11 @@ function InstanceAdminCard({
<div className="inst-head">
<span className="inst-name">{inst.name}</span>
<span className={'tag ' + badge.cls}>{badge.text}</span>
{outdated && !acting && (
<span className="tag tag-warn" title="该实例的镜像落后于最新版,点「升级实例」可更新">
</span>
)}
</div>
<div className="inst-sub">
{sub}

View File

@@ -1017,6 +1017,23 @@ button {
background: rgba(245 158 11 / 0.1);
border-radius: 10px;
}
.upgrade-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin: 2px 0 14px;
padding: 11px 14px;
border-radius: 14px;
background: rgba(245 158 11 / 0.12);
color: var(--text);
font-size: 13.5px;
line-height: 1.5;
}
.upgrade-banner .s-btn {
flex: none;
}
.ver-hint code {
font-size: 12px;
padding: 1px 5px;