mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-08-31 02:31:27 -07:00
feat(panel): 面板内「一键更新面板」(自更新 + 失败回滚)
面板本身的更新此前必须去 docker/飞牛手动 pull + 重建。现支持在「关于」里一键更新(仅在检测到 新版时出现,管理员可见): - 机制:面板先拉新镜像,再派生一个临时 helper 容器(用新镜像 + docker.sock)重建 woc-panel: 停旧 → 按旧容器配置(env 用「新镜像 baked + compose 注入」合并,保留 Labels/HostConfig/网络) 用新镜像重建 → 起来 → 稳定运行检查;失败则回滚旧镜像。helper 干完退出。 - 最坏情况(helper 也挂)用户 docker compose up -d 即可恢复(与手动一致)。 - 已本地实测:成功路径(重建后稳定、env/网络/label 保留)+ 回滚路径(坏镜像→回滚救活), 且全程不影响真 woc-panel。 - 新增 POST /api/admin/version/self-update + api.selfUpdatePanel + 「关于」按钮。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts"
|
||||
"start": "tsx src/index.ts",
|
||||
"updater": "tsx src/updater-entry.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
|
||||
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
|
||||
import { CURRENT_VERSION, versionInfo, ensureChecked, checkForUpdate, startUpdateChecker } from './version.js';
|
||||
import { triggerSelfUpdate } from './self-update.js';
|
||||
import { appendInstanceLog, readInstanceLog, appendPanelLog, readPanelLog, pruneOldLogs, filterSince, rangeToMs, DIAG_RANGES } from './logs.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -186,6 +187,19 @@ app.post('/api/admin/version/check', async (req, reply) => {
|
||||
return await checkForUpdate();
|
||||
});
|
||||
|
||||
// 一键更新面板自身(管理员):拉新镜像 → 派生 helper 容器重建 woc-panel(带健康检查 + 失败回滚)。
|
||||
// 返回后面板会在十几秒内被 helper 重启,前端提示用户稍候刷新。
|
||||
app.post('/api/admin/version/self-update', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
try {
|
||||
const { target } = await triggerSelfUpdate();
|
||||
return { ok: true, target, message: '已开始更新:面板将在十几秒内重启为新版本,请稍候刷新页面' };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `面板自更新失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '更新失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 自助改密 ----------
|
||||
app.post('/api/account/password', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
|
||||
191
panel/server/src/self-update.ts
Normal file
191
panel/server/src/self-update.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// 面板自更新(watchtower 式):面板容器无法干净地"重建自己"(执行到删除自己时进程就没了),
|
||||
// 故由面板先拉新镜像,再派生一个【临时 helper 容器】(用新镜像 + docker.sock)去重建 woc-panel:
|
||||
// stop+rm 旧面板 → 用新镜像按旧容器配置重建 → 起来 → 健康检查(稳定运行)→ 成功;
|
||||
// 若新面板起不来 → 回滚:用旧镜像重新建回面板。helper 干完自行退出。
|
||||
// 最坏情况(helper 也挂)用户 `docker compose up -d` 即可恢复(与手动方式一致)。
|
||||
//
|
||||
// 配置复刻要点:
|
||||
// - Env:用「新镜像 baked env」+「旧容器相对旧镜像多出来的 env」(即 compose 注入的运行时变量),
|
||||
// 这样新 WOC_VERSION 来自新镜像、PANEL_* 等运行时变量保留(不会把旧 WOC_VERSION 带进去)。
|
||||
// - Labels:保留(含 com.docker.compose.*,让 compose 之后仍认得这个容器)。
|
||||
// - HostConfig:整体复用(binds/docker.sock/端口/重启策略/网络模式)。
|
||||
// - 网络:复用全部已连接网络(主网络在 create 时给,其余 create 后 connect)。
|
||||
|
||||
import Docker from 'dockerode';
|
||||
import { appendPanelLog } from './logs.js';
|
||||
|
||||
const docker = new Docker();
|
||||
const PANEL_NAME = process.env.WOC_PANEL_CONTAINER || 'woc-panel';
|
||||
const UPDATER_NAME = PANEL_NAME + '-updater';
|
||||
|
||||
function pull(ref: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
docker.pull(ref, (err: any, stream: NodeJS.ReadableStream) => {
|
||||
if (err) return reject(err);
|
||||
docker.modem.followProgress(stream, (e: any) => (e ? reject(e) : resolve()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function envToMap(env?: string[] | null): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const e of env || []) {
|
||||
const i = e.indexOf('=');
|
||||
if (i > 0) m.set(e.slice(0, i), e.slice(i + 1));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// 由旧容器 inspect + 目标镜像,构造重建用的 create 选项(含 env-diff 与网络)。
|
||||
async function buildCreateOpts(self: any, imageRef: string): Promise<Docker.ContainerCreateOptions> {
|
||||
const newImg: any = await docker.getImage(imageRef).inspect();
|
||||
let oldBaked = new Map<string, string>();
|
||||
try {
|
||||
const oldImg: any = await docker.getImage(self.Image).inspect(); // self.Image = 旧镜像 id
|
||||
oldBaked = envToMap(oldImg.Config?.Env);
|
||||
} catch {
|
||||
/* 旧镜像可能已被新 tag 覆盖且无法 inspect:oldBaked 为空,下面会多带几个 baked env,无害 */
|
||||
}
|
||||
const newBaked = envToMap(newImg.Config?.Env);
|
||||
const containerEnv = envToMap(self.Config?.Env);
|
||||
const finalEnv = new Map(newBaked); // 起步:新镜像 baked(含新 WOC_VERSION)
|
||||
for (const [k, v] of containerEnv) {
|
||||
// compose 注入或覆盖的(旧镜像没有该 key,或容器值与旧 baked 不同)→ 保留
|
||||
if (!oldBaked.has(k) || oldBaked.get(k) !== v) finalEnv.set(k, v);
|
||||
}
|
||||
const cfg = self.Config || {};
|
||||
const nets: Record<string, any> = self.NetworkSettings?.Networks || {};
|
||||
const netNames = Object.keys(nets);
|
||||
const opts: Docker.ContainerCreateOptions = {
|
||||
name: String(self.Name || '').replace(/^\//, '') || PANEL_NAME, // 用目标容器自身名字,而非硬编码常量
|
||||
Image: imageRef,
|
||||
Hostname: cfg.Hostname,
|
||||
User: cfg.User || undefined,
|
||||
Env: [...finalEnv].map(([k, v]) => `${k}=${v}`),
|
||||
Cmd: cfg.Cmd || undefined,
|
||||
Entrypoint: cfg.Entrypoint || undefined,
|
||||
Labels: cfg.Labels || undefined,
|
||||
WorkingDir: cfg.WorkingDir || undefined,
|
||||
ExposedPorts: cfg.ExposedPorts || undefined,
|
||||
HostConfig: self.HostConfig,
|
||||
};
|
||||
if (netNames.length) {
|
||||
const primary = netNames[0];
|
||||
const aliases = (nets[primary].Aliases || []).filter((a: string) => !String(self.Id).startsWith(a));
|
||||
opts.NetworkingConfig = { EndpointsConfig: { [primary]: { Aliases: aliases } } };
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// 面板侧:拉新镜像 + 派生 helper 容器重建自身。返回目标镜像。
|
||||
export async function triggerSelfUpdate(): Promise<{ target: string }> {
|
||||
const self: any = await docker.getContainer(PANEL_NAME).inspect();
|
||||
const ref: string = self.Config.Image; // 如 docker.io/gloridust/woc-panel:latest 或 :v1.2.1
|
||||
const repo = ref.split('@')[0].replace(/:[^/:]+$/, ''); // 去 tag
|
||||
const target = `${repo}:latest`; // 拉最新发布
|
||||
appendPanelLog('WARN', `面板自更新:开始拉取 ${target}`);
|
||||
await pull(target);
|
||||
appendPanelLog('INFO', `面板自更新:${target} 已拉取,派生 ${UPDATER_NAME} 容器重建面板(数据保留)`);
|
||||
|
||||
const spec = { panelName: PANEL_NAME, newImage: target, oldImageId: self.Image };
|
||||
// 仅需 docker.sock;spec 经 env 传入,不依赖 /data 挂载,避免路径不一致。
|
||||
const sockBind =
|
||||
(self.HostConfig.Binds || []).find((b: string) => b.includes('docker.sock')) || '/var/run/docker.sock:/var/run/docker.sock';
|
||||
try {
|
||||
await docker.getContainer(UPDATER_NAME).remove({ force: true });
|
||||
} catch {
|
||||
/* 无旧 updater,正常 */
|
||||
}
|
||||
const helper = await docker.createContainer({
|
||||
name: UPDATER_NAME,
|
||||
Image: target,
|
||||
Env: ['WOC_UPDATER=1', `WOC_UPDATER_SPEC=${JSON.stringify(spec)}`],
|
||||
Cmd: ['npm', 'run', 'updater'],
|
||||
HostConfig: {
|
||||
Binds: [sockBind],
|
||||
NetworkMode: self.HostConfig.NetworkMode, // 与面板同网,便于健康检查按名访问
|
||||
RestartPolicy: { Name: 'no' },
|
||||
AutoRemove: false, // 保留日志便于排查;下次更新前会先删旧 updater
|
||||
},
|
||||
});
|
||||
await helper.start();
|
||||
return { target };
|
||||
}
|
||||
|
||||
// 等容器稳定运行:在 timeoutMs 内,State.Running 持续为真且无新的 crash/退出,视为健康。
|
||||
async function waitStable(name: string, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let stableSince = 0;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
try {
|
||||
const info: any = await docker.getContainer(name).inspect();
|
||||
const s = info.State || {};
|
||||
if (s.Running && !s.Restarting) {
|
||||
if (!stableSince) stableSince = Date.now();
|
||||
if (Date.now() - stableSince >= 8000) return true; // 连续稳定运行 8s
|
||||
} else {
|
||||
stableSince = 0; // 退出/重启中 → 重新计时
|
||||
}
|
||||
} catch {
|
||||
stableSince = 0; // 容器暂不可读(重建间隙)
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// helper 侧:读取 spec,重建面板;失败回滚到旧镜像。
|
||||
export async function runUpdaterRecreate(): Promise<void> {
|
||||
const spec = JSON.parse(process.env.WOC_UPDATER_SPEC || '{}');
|
||||
const { panelName, newImage, oldImageId } = spec;
|
||||
if (!panelName || !newImage) {
|
||||
console.error('[updater] 缺少 spec,退出');
|
||||
return;
|
||||
}
|
||||
console.log(`[updater] 重建面板 ${panelName} → ${newImage}`);
|
||||
const self: any = await docker.getContainer(panelName).inspect(); // 先抓旧配置(停之前)
|
||||
const otherNets = Object.keys(self.NetworkSettings?.Networks || {}).slice(1);
|
||||
|
||||
const recreate = async (imageRef: string) => {
|
||||
const opts = await buildCreateOpts(self, imageRef);
|
||||
try {
|
||||
await docker.getContainer(panelName).remove({ force: true });
|
||||
} catch {
|
||||
/* 已删/不存在 */
|
||||
}
|
||||
const c = await docker.createContainer(opts);
|
||||
for (const net of otherNets) {
|
||||
try {
|
||||
await docker.getNetwork(net).connect({ Container: c.id });
|
||||
} catch {
|
||||
/* 次要网络连接失败不致命 */
|
||||
}
|
||||
}
|
||||
await c.start();
|
||||
return c;
|
||||
};
|
||||
|
||||
// 先停旧面板
|
||||
try {
|
||||
await docker.getContainer(panelName).stop({ t: 8 } as any);
|
||||
} catch {
|
||||
/* 已停 */
|
||||
}
|
||||
try {
|
||||
await recreate(newImage);
|
||||
if (await waitStable(panelName, 60000)) {
|
||||
console.log('[updater] 面板更新成功,新版本已稳定运行');
|
||||
return;
|
||||
}
|
||||
console.error('[updater] 新面板未在限时内稳定运行 → 回滚旧镜像');
|
||||
} catch (e) {
|
||||
console.error('[updater] 重建新面板失败 → 回滚旧镜像:', e);
|
||||
}
|
||||
// 回滚
|
||||
try {
|
||||
await recreate(oldImageId);
|
||||
console.error('[updater] 已回滚到旧版本面板(更新失败)');
|
||||
} catch (e) {
|
||||
console.error('[updater] 回滚失败,请手动 `docker compose up -d` 恢复:', e);
|
||||
}
|
||||
}
|
||||
10
panel/server/src/updater-entry.ts
Normal file
10
panel/server/src/updater-entry.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// 临时 helper 容器的入口(由 triggerSelfUpdate 以 `npm run updater` 启动,env WOC_UPDATER=1)。
|
||||
// 只做一件事:重建面板,然后退出。
|
||||
import { runUpdaterRecreate } from './self-update.js';
|
||||
|
||||
runUpdaterRecreate()
|
||||
.then(() => process.exit(0))
|
||||
.catch((e) => {
|
||||
console.error('[updater] 异常退出:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -128,6 +128,8 @@ export const api = {
|
||||
// 版本与更新检测
|
||||
getVersion: () => req<VersionInfo>('/api/version'),
|
||||
checkUpdate: () => req<VersionInfo>('/api/admin/version/check', { method: 'POST' }),
|
||||
// 一键更新面板自身:拉新镜像 + 派生 helper 容器重建 woc-panel(带回滚)。返回后面板会重启。
|
||||
selfUpdatePanel: () => req<{ ok: boolean; target: string; message: string }>('/api/admin/version/self-update', { method: 'POST' }),
|
||||
|
||||
// 子账号
|
||||
listUsers: () => req<{ users: PanelUser[] }>('/api/admin/users'),
|
||||
|
||||
@@ -134,14 +134,35 @@ function DiagnosticsSection() {
|
||||
|
||||
// 「关于」:显示真实构建版本号 + 检测新版(后台已每 6h 查 Docker Hub/GHCR;这里读缓存并可手动重查)。
|
||||
function AboutSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { toast } = useUI();
|
||||
const { toast, confirm } = useUI();
|
||||
const [info, setInfo] = useState<VersionInfo | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getVersion().then(setInfo).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 一键更新面板:拉新镜像 + 派生 helper 容器重建 woc-panel(数据保留,带失败回滚)。
|
||||
// 触发后面板会被重建、本连接短暂中断,约 20s 后自动刷新到新版本。
|
||||
const selfUpdate = async () => {
|
||||
const ok = await confirm({
|
||||
title: '一键更新面板?',
|
||||
body: `将拉取最新镜像并重建面板容器(数据/登录保留),约十几秒、期间面板会短暂重启,完成后自动刷新。${info?.latest ? `\n目标版本:${info.latest}` : ''}`,
|
||||
confirmText: '更新',
|
||||
});
|
||||
if (!ok) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
const r = await api.selfUpdatePanel();
|
||||
toast(r.message || '已开始更新,面板将重启,请稍候…', 'ok');
|
||||
window.setTimeout(() => window.location.reload(), 25000); // 等新面板起来后自动刷新
|
||||
} catch (e: any) {
|
||||
toast(e.message || '更新失败', 'error');
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当前版本是否为正式发布版(语义化 vX.Y.Z)。dev / dev-<sha> 等本地构建无法与发布版比较,
|
||||
// 既不显示「已是最新」也不显示红点,只把最新发布版作为信息展示。
|
||||
const isRelease = !!info && /^v?\d+\.\d+\.\d+$/.test(info.current);
|
||||
@@ -189,17 +210,24 @@ function AboutSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
</p>
|
||||
{info?.hasUpdate && (
|
||||
<div className="ver-hint">
|
||||
在宿主执行 <code>docker compose pull && docker compose up -d</code> 升级面板;各实例镜像可在「管理 → 升级」单独更新。
|
||||
{isAdmin
|
||||
? '点「一键更新面板」即可自动拉新镜像并重建面板(数据/登录保留,约十几秒、期间会短暂重启,完成后自动刷新)。各实例镜像可在「管理 → 升级」单独更新。'
|
||||
: '面板有新版本,请联系管理员更新。'}
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-actions">
|
||||
{info?.hasUpdate && isAdmin && (
|
||||
<button className="btn btn-primary s-btn" disabled={updating} onClick={selfUpdate}>
|
||||
{updating ? '更新中…请稍候' : '一键更新面板'}
|
||||
</button>
|
||||
)}
|
||||
{info?.hasUpdate && (
|
||||
<a className="btn btn-primary s-btn" href={RELEASES_URL + '/latest'} target="_blank" rel="noreferrer">
|
||||
查看新版
|
||||
<a className="btn-text" href={RELEASES_URL + '/latest'} target="_blank" rel="noreferrer">
|
||||
查看新版 ›
|
||||
</a>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="btn-text" disabled={checking} onClick={check}>
|
||||
<button className="btn-text" disabled={checking || updating} onClick={check}>
|
||||
{checking ? '检查中…' : '检查更新'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user