mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-09-20 11:33:32 +08:00
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:
@@ -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}'`]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user