From db894cfcff9448ceabde1e9baf86892f2f1b70a5 Mon Sep 17 00:00:00 2001 From: Gloridust Date: Sun, 5 Jul 2026 01:15:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(update):=20=E5=AE=9E=E4=BE=8B=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E8=90=BD=E5=90=8E=E6=A3=80=E6=B5=8B=20+=20=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=8D=87=E7=BA=A7=E5=85=A8=E9=83=A8=20+=20=E5=A3=81?= =?UTF-8?q?=E7=BA=B8=E7=BC=BA=E5=B7=A5=E5=85=B7=E5=8F=8B=E5=A5=BD=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面板与实例是两套镜像:更新面板不会动实例,用户常"更新了面板、实例还旧"(如设壁纸报 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) --- panel/server/src/docker.ts | 36 ++++++++++++++++++++++ panel/server/src/index.ts | 38 +++++++++++++++++++++++ panel/web/src/api.ts | 7 +++++ panel/web/src/pages/Admin.tsx | 58 ++++++++++++++++++++++++++++++++++- panel/web/src/styles.css | 17 ++++++++++ 5 files changed, 155 insertions(+), 1 deletion(-) diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index 0395b96..5d7bba2 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -470,6 +470,29 @@ export async function instanceRuntime(inst: Instance): Promise { } } +// 本地「最新实例镜像」的 Id(新建/升级实例会用到的镜像)。查不到(未拉取过)返回 null。 +export async function latestInstanceImageId(): Promise { + 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 { + 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 { + 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 { 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}'`]); } diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 9ee6512..72a9a53 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -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; diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index 4406e2f..f95d28e 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -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)}`, diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index e4f00cb..8d1535c 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -138,10 +138,12 @@ function AboutSection({ isAdmin }: { isAdmin: boolean }) { const [info, setInfo] = useState(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 }) { : '点「一键更新面板」即可自动拉新镜像并重建面板(数据/登录保留,约十几秒、期间会短暂重启,完成后自动刷新)。各实例镜像可在「管理 → 升级」单独更新。'} )} + {isAdmin && outdatedInst > 0 && ( +
+ ⚠️ 另有 {outdatedInst} 个实例的镜像可升级。更新面板不会自动升级实例(二者是不同镜像)——请到「管理」用「一键升级全部实例」。 +
+ )}
{info?.hasUpdate && isAdmin && (
+ {!!upg?.outdatedCount && ( +
+ + 有 {upg.outdatedCount} 个实例的镜像可升级到最新版。 + (更新面板不会自动升级实例,二者是不同镜像) + + +
+ )} {instances.length === 0 ? ( 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({
{inst.name} {badge.text} + {outdated && !acting && ( + + 可升级 + + )}
{sub} diff --git a/panel/web/src/styles.css b/panel/web/src/styles.css index 6148a10..63d0012 100644 --- a/panel/web/src/styles.css +++ b/panel/web/src/styles.css @@ -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;