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:
Gloridust
2026-06-17 16:43:25 +08:00
parent 998a57ca25
commit f939538113
6 changed files with 252 additions and 6 deletions

View File

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

View File

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

View 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 覆盖且无法 inspectoldBaked 为空,下面会多带几个 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.sockspec 经 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);
}
}

View 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);
});

View File

@@ -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'),

View File

@@ -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 &amp;&amp; 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>
)}