mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-08-30 18:21:27 -07:00
fix(critical): xsettingsd Xft/DPI 单位错误 —— v1.2.9~v1.3.1 微信公众号/附件秒关、浏览器黑屏的总根因
XSETTINGS 规范中 Xft/DPI 的单位是「DPI×1024」(96 DPI 应写 98304),#101 引入的 xsettingsd 配置误写成 96 → 所有 Chromium 内核应用把 DPI 读成 96/1024≈0.09 → 缩放因子≈0 → 显示变换 矩阵 [0 0 0 0;…] 不可逆(transform.cc NOTREACHED)→ GPU/viz 进程连崩 3 次 → 窗口/应用退出: - 微信内嵌 CEF:点公众号/附件/小程序 → 窗口秒关/白屏(issue #111,日志与本地复现完全吻合) - 系统 Chromium:整个浏览器崩溃循环 → 黑屏(v1.3.1 的 --force-device-scale-factor=1 只救了 能加 flag 的 Chromium,是治症状;微信 CEF 无法加 flag,必须修 XSETTINGS 源头) A/B 实证:Xft/DPI 96 → 裸 chromium 立崩(同款堆栈);改 98304 → 存活。两处写入点均已修 (docker/autostart 每次开机重写 + 面板 applyXsettingsFont 实时下发)。 配套修复本轮反馈的其余问题: - 磁盘暴涨("1TB 没了"):实例容器注入 LogConfig 20m×2(默认 json-file 无上限,崩溃循环 每 2s 刷错顶满磁盘);compose 面板同配;微信 crashinfo 崩溃转储 1 天过期清理; 一键升级完成后清理悬空(<none>)旧镜像并记录释放空间。 - 一键升级"卡死":改异步——立即返回、后台顺序执行、前端轮询进度(n/总数·当前实例); 镜像统一只拉一次(原每实例各拉一次,受限网络下每次都等停滞超时)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,4 +56,12 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${WOC_HTTP_PORT:-36080}:8080" # 面板 = 唯一对外入口
|
- "${WOC_HTTP_PORT:-36080}:8080" # 面板 = 唯一对外入口
|
||||||
|
|
||||||
|
# 日志硬上限:docker 默认 json-file 不限大小,长期运行/异常刷错会无限吃磁盘。
|
||||||
|
# (实例容器的同类上限由面板创建时注入,无需在此配置。)
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "2"
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -145,12 +145,16 @@ if command -v xsettingsd >/dev/null 2>&1; then
|
|||||||
family=$(cat "$FONT_FAMILY_FILE")
|
family=$(cat "$FONT_FAMILY_FILE")
|
||||||
[ -n "$family" ] && XSETTINGS_FONT="$family"
|
[ -n "$family" ] && XSETTINGS_FONT="$family"
|
||||||
fi
|
fi
|
||||||
|
# ⚠️ XSETTINGS 规范里 Xft/DPI 的单位是「DPI × 1024」:96 DPI 必须写 98304。
|
||||||
|
# 若误写 96,所有 Chromium 内核应用(系统 Chromium、微信内嵌 CEF)会把 DPI 读成 96/1024≈0.09
|
||||||
|
# → 缩放因子≈0 → 显示变换矩阵不可逆(transform.cc NOTREACHED) → GPU 进程连崩 → 窗口秒关/黑屏。
|
||||||
|
# 这曾是 v1.2.9~v1.3.1 微信打不开公众号/附件、浏览器黑屏的总根因(issue #111)。
|
||||||
cat > "${XSETTINGS_CONF}" << EOF
|
cat > "${XSETTINGS_CONF}" << EOF
|
||||||
Xft/Antialias 1
|
Xft/Antialias 1
|
||||||
Xft/Hinting 1
|
Xft/Hinting 1
|
||||||
Xft/HintStyle "hintslight"
|
Xft/HintStyle "hintslight"
|
||||||
Xft/RGBA "rgb"
|
Xft/RGBA "rgb"
|
||||||
Xft/DPI 96
|
Xft/DPI 98304
|
||||||
Gtk/FontName "${XSETTINGS_FONT} 10"
|
Gtk/FontName "${XSETTINGS_FONT} 10"
|
||||||
EOF
|
EOF
|
||||||
xsettingsd --config="${XSETTINGS_CONF}" 2>/dev/null &
|
xsettingsd --config="${XSETTINGS_CONF}" 2>/dev/null &
|
||||||
@@ -167,6 +171,15 @@ while [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; do
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# 3.5) 清理微信崩溃转储(crashinfo)。
|
||||||
|
# 微信内嵌浏览器崩溃时 crashpad 会往 /config/.xwechat/crashinfo 里堆 .dmp 转储与 lock/meta 文件;
|
||||||
|
# 崩溃风暴(如 DPI bug 期间每次点公众号都崩)会让它无限膨胀吃满磁盘,且启动时反复扫描刷日志。
|
||||||
|
# 转储仅用于事后诊断,保留 1 天足够;过期即删,防磁盘被吃。
|
||||||
|
if [ -d /config/.xwechat/crashinfo ]; then
|
||||||
|
find /config/.xwechat/crashinfo -type f -mtime +1 -delete 2>/dev/null || true
|
||||||
|
find /config/.xwechat/crashinfo -mindepth 1 -type d -empty -delete 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# 3) 常驻拉起应用
|
# 3) 常驻拉起应用
|
||||||
while true; do
|
while true; do
|
||||||
if [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; then
|
if [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; then
|
||||||
|
|||||||
@@ -223,6 +223,9 @@ export async function runInstance(inst: Instance): Promise<void> {
|
|||||||
SecurityOpt: ['seccomp=unconfined'],
|
SecurityOpt: ['seccomp=unconfined'],
|
||||||
ShmSize: SHM_SIZE,
|
ShmSize: SHM_SIZE,
|
||||||
RestartPolicy: { Name: 'unless-stopped' },
|
RestartPolicy: { Name: 'unless-stopped' },
|
||||||
|
// 日志硬上限:docker 默认 json-file 无大小限制,应用崩溃循环(每 2s 刷错误)会把宿主磁盘
|
||||||
|
// 无限吃掉(群晖用户反馈"一下子 1TB 没了"的元凶之一)。每实例封顶 20MB×2。
|
||||||
|
LogConfig: { Type: 'json-file', Config: { 'max-size': '20m', 'max-file': '2' } },
|
||||||
};
|
};
|
||||||
if (INSTANCE_MEM > 0) {
|
if (INSTANCE_MEM > 0) {
|
||||||
hostConfig.Memory = INSTANCE_MEM;
|
hostConfig.Memory = INSTANCE_MEM;
|
||||||
@@ -293,15 +296,31 @@ export async function ensureRunning(inst: Instance): Promise<void> {
|
|||||||
|
|
||||||
// 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。
|
// 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。
|
||||||
// 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。
|
// 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。
|
||||||
export async function upgradeInstance(inst: Instance): Promise<void> {
|
// skipPull:批量升级时由调用方先统一拉取一次,避免 N 个实例拉 N 次(受限网络下每次
|
||||||
|
// 都要等到拉取停滞超时,表现为"一键升级卡死")。
|
||||||
|
export async function upgradeInstance(inst: Instance, opts?: { skipPull?: boolean }): Promise<void> {
|
||||||
|
if (!opts?.skipPull) {
|
||||||
try {
|
try {
|
||||||
await pullImage();
|
await pullImage();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e);
|
console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await runInstance(inst);
|
await runInstance(inst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理悬空(dangling)镜像:升级后旧实例镜像失去 tag 变成 <none>,长期堆积吃磁盘
|
||||||
|
// (每层 1-2GB,多次升级后可观)。只删无 tag 且无容器引用的镜像,安全。best-effort。
|
||||||
|
export async function pruneDanglingImages(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res: any = await docker.pruneImages({ filters: { dangling: ['true'] } as any });
|
||||||
|
const freed = Number(res?.SpaceReclaimed || 0);
|
||||||
|
if (freed > 0) appendPanelLog('INFO', `已清理悬空镜像,释放 ${(freed / 1024 / 1024 / 1024).toFixed(2)} GB`);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[docker] 清理悬空镜像失败(忽略):', e?.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 重置实例的设备 machine-id:删掉持久化的 .woc-machine-id 后重启,由 00-woc-identity 钩子重新生成
|
// 重置实例的设备 machine-id:删掉持久化的 .woc-machine-id 后重启,由 00-woc-identity 钩子重新生成
|
||||||
// 一个全新的唯一值(相当于"换一台新设备")。用于某账号被腾讯风控标记后手动滚新设备身份。
|
// 一个全新的唯一值(相当于"换一台新设备")。用于某账号被腾讯风控标记后手动滚新设备身份。
|
||||||
// 仅对含身份钩子的新镜像有效;旧镜像(升级前)无钩子,先 throw 提示升级,避免做无用功。
|
// 仅对含身份钩子的新镜像有效;旧镜像(升级前)无钩子,先 throw 提示升级,避免做无用功。
|
||||||
@@ -1214,12 +1233,15 @@ export async function applyFont(inst: Instance, fontFile: string): Promise<void>
|
|||||||
|
|
||||||
async function applyXsettingsFont(inst: Instance, family: string): Promise<void> {
|
async function applyXsettingsFont(inst: Instance, family: string): Promise<void> {
|
||||||
const conf = '/config/.xsettingsd';
|
const conf = '/config/.xsettingsd';
|
||||||
|
// ⚠️ XSETTINGS 规范里 Xft/DPI 单位是「DPI × 1024」:96 DPI 必须写 98304。误写 96 会让所有
|
||||||
|
// Chromium 内核应用(系统 Chromium / 微信内嵌 CEF)把缩放因子算成≈0 → 变换矩阵不可逆 →
|
||||||
|
// GPU 进程连崩 → 窗口秒关/黑屏(v1.2.9~v1.3.1 的总根因,issue #111)。
|
||||||
const lines = [
|
const lines = [
|
||||||
'Xft/Antialias 1',
|
'Xft/Antialias 1',
|
||||||
'Xft/Hinting 1',
|
'Xft/Hinting 1',
|
||||||
'Xft/HintStyle "hintslight"',
|
'Xft/HintStyle "hintslight"',
|
||||||
'Xft/RGBA "rgb"',
|
'Xft/RGBA "rgb"',
|
||||||
'Xft/DPI 96',
|
'Xft/DPI 98304',
|
||||||
`Gtk/FontName "${family} 10"`,
|
`Gtk/FontName "${family} 10"`,
|
||||||
];
|
];
|
||||||
await execCapture(inst, ['sh', '-c', `printf '%s\\n' ${lines.map(l => `'${l}'`).join(' ')} > ${conf}`]);
|
await execCapture(inst, ['sh', '-c', `printf '%s\\n' ${lines.map(l => `'${l}'`).join(' ')} > ${conf}`]);
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import {
|
|||||||
upgradeInstance,
|
upgradeInstance,
|
||||||
latestInstanceImageId,
|
latestInstanceImageId,
|
||||||
instanceOutdated,
|
instanceOutdated,
|
||||||
|
pullImage,
|
||||||
|
pruneDanglingImages,
|
||||||
removeInstance as removeInstanceContainer,
|
removeInstance as removeInstanceContainer,
|
||||||
instanceRuntime,
|
instanceRuntime,
|
||||||
triggerWechat,
|
triggerWechat,
|
||||||
@@ -663,30 +665,55 @@ app.get('/api/admin/instances/upgrade-status', async (req, reply) => {
|
|||||||
list.map(async (inst) => ({ id: inst.id, name: inst.name, outdated: await instanceOutdated(inst, latestId) })),
|
list.map(async (inst) => ({ id: inst.id, name: inst.name, outdated: await instanceOutdated(inst, latestId) })),
|
||||||
);
|
);
|
||||||
const outdated = results.filter((r) => r.outdated);
|
const outdated = results.filter((r) => r.outdated);
|
||||||
return { known: !!latestId, outdatedCount: outdated.length, outdatedIds: outdated.map((r) => r.id), instances: results };
|
return {
|
||||||
|
known: !!latestId,
|
||||||
|
outdatedCount: outdated.length,
|
||||||
|
outdatedIds: outdated.map((r) => r.id),
|
||||||
|
instances: results,
|
||||||
|
upgradeAll: upgradeAllState, // 一键升级进行中的进度(running=false 表示空闲/已完成)
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 一键升级全部"镜像落后"的实例(顺序执行,逐个 best-effort;个别失败不影响其余)。
|
// 一键升级全部"镜像落后"的实例。
|
||||||
|
// 异步化:拉镜像 + 逐个重建可能耗时数分钟到更久(受限网络下拉取要等到停滞超时),同步等待会让
|
||||||
|
// 前端请求悬死、代理超时——用户反馈"一键升级一直卡死"。改为:立即返回,后台顺序执行,
|
||||||
|
// 前端轮询 upgrade-status 里的 upgradeAll 进度。镜像只拉一次(不再每实例各拉一次)。
|
||||||
|
let upgradeAllState = { running: false, total: 0, done: 0, failed: 0, phase: '' };
|
||||||
app.post('/api/admin/instances/upgrade-all', async (req, reply) => {
|
app.post('/api/admin/instances/upgrade-all', async (req, reply) => {
|
||||||
if (!requireAdmin(req, reply)) return;
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
if (upgradeAllState.running) return reply.code(409).send({ error: '一键升级正在进行中,请等待完成' });
|
||||||
const latestId = await latestInstanceImageId();
|
const latestId = await latestInstanceImageId();
|
||||||
if (!latestId) return reply.code(409).send({ error: '本地尚无实例镜像,无法判断是否需要升级(请先联网拉取)' });
|
if (!latestId) return reply.code(409).send({ error: '本地尚无实例镜像,无法判断是否需要升级(请先联网拉取)' });
|
||||||
const list = listInstances();
|
const list = listInstances();
|
||||||
let upgraded = 0;
|
const outdated: typeof list = [];
|
||||||
let failed = 0;
|
for (const inst of list) if (await instanceOutdated(inst, latestId)) outdated.push(inst);
|
||||||
for (const inst of list) {
|
if (!outdated.length) return { ok: true, started: false, upgraded: 0, failed: 0 };
|
||||||
if (!(await instanceOutdated(inst, latestId))) continue;
|
|
||||||
|
upgradeAllState = { running: true, total: outdated.length, done: 0, failed: 0, phase: '拉取最新镜像…' };
|
||||||
|
void (async () => {
|
||||||
|
// 统一拉取一次(失败不阻断:用本地已有镜像重建)
|
||||||
|
try {
|
||||||
|
await pullImage();
|
||||||
|
} catch (e: any) {
|
||||||
|
appendPanelLog('WARN', `一键升级:拉取镜像失败(${e?.message || e}),改用本地镜像重建`);
|
||||||
|
}
|
||||||
|
for (const inst of outdated) {
|
||||||
|
upgradeAllState.phase = `升级「${inst.name}」…`;
|
||||||
try {
|
try {
|
||||||
appendPanelLog('INFO', `一键升级实例「${inst.name}」(id=${inst.id})…`);
|
appendPanelLog('INFO', `一键升级实例「${inst.name}」(id=${inst.id})…`);
|
||||||
await upgradeInstance(inst);
|
await upgradeInstance(inst, { skipPull: true });
|
||||||
upgraded++;
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
failed++;
|
upgradeAllState.failed++;
|
||||||
appendPanelLog('ERROR', `一键升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
appendPanelLog('ERROR', `一键升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||||
}
|
}
|
||||||
|
upgradeAllState.done++;
|
||||||
}
|
}
|
||||||
appendPanelLog('INFO', `一键升级全部实例完成:成功 ${upgraded}、失败 ${failed}`);
|
appendPanelLog('INFO', `一键升级全部实例完成:成功 ${upgradeAllState.done - upgradeAllState.failed}、失败 ${upgradeAllState.failed}`);
|
||||||
return { ok: true, upgraded, failed };
|
// 升级后旧镜像变悬空(<none>),顺手清理防磁盘堆积
|
||||||
|
await pruneDanglingImages();
|
||||||
|
upgradeAllState = { ...upgradeAllState, running: false, phase: '' };
|
||||||
|
})();
|
||||||
|
return { ok: true, started: true, total: outdated.length };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 实例侧:设置该实例可被哪些账户访问
|
// 实例侧:设置该实例可被哪些账户访问
|
||||||
|
|||||||
@@ -202,13 +202,17 @@ export const api = {
|
|||||||
instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }),
|
instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }),
|
||||||
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
|
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
|
||||||
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
|
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
|
||||||
// 实例镜像升级状态(哪些实例落后于最新镜像)+ 一键升级全部落后实例。
|
// 实例镜像升级状态(哪些实例落后于最新镜像 + 一键升级进行中的进度)+ 发起一键升级(异步,立即返回,轮询进度)。
|
||||||
upgradeStatus: () =>
|
upgradeStatus: () =>
|
||||||
req<{ known: boolean; outdatedCount: number; outdatedIds: string[]; instances: { id: string; name: string; outdated: boolean }[] }>(
|
req<{
|
||||||
'/api/admin/instances/upgrade-status',
|
known: boolean;
|
||||||
),
|
outdatedCount: number;
|
||||||
|
outdatedIds: string[];
|
||||||
|
instances: { id: string; name: string; outdated: boolean }[];
|
||||||
|
upgradeAll: { running: boolean; total: number; done: number; failed: number; phase: string };
|
||||||
|
}>('/api/admin/instances/upgrade-status'),
|
||||||
upgradeAllInstances: () =>
|
upgradeAllInstances: () =>
|
||||||
req<{ ok: boolean; upgraded: number; failed: number }>('/api/admin/instances/upgrade-all', { method: 'POST' }),
|
req<{ ok: boolean; started: boolean; total?: number }>('/api/admin/instances/upgrade-all', { method: 'POST' }),
|
||||||
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
|
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
|
||||||
// 全局日志 / 诊断包(范围 24h/7d/30d/1y)
|
// 全局日志 / 诊断包(范围 24h/7d/30d/1y)
|
||||||
diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`,
|
diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`,
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
|||||||
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
|
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
|
||||||
const [upg, setUpg] = useState<{ outdatedCount: number; outdatedIds: string[] } | null>(null); // 镜像落后的实例
|
const [upg, setUpg] = useState<{ outdatedCount: number; outdatedIds: string[] } | null>(null); // 镜像落后的实例
|
||||||
const [upgradingAll, setUpgradingAll] = useState(false);
|
const [upgradingAll, setUpgradingAll] = useState(false);
|
||||||
|
const [upgProgress, setUpgProgress] = useState(''); // 一键升级进度文案("2/5 · 升级「xxx」…")
|
||||||
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
|
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
|
||||||
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
|
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
|
||||||
// 残留 woc-wx-* 容器(runInstance 启动失败遗留的 Created 容器等):占着卷名让删卷报 409。
|
// 残留 woc-wx-* 容器(runInstance 启动失败遗留的 Created 容器等):占着卷名让删卷报 409。
|
||||||
@@ -412,25 +413,51 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 一键升级全部"镜像落后"的实例(逐个拉新镜像重建,可能几分钟)。
|
// 一键升级全部"镜像落后"的实例。后端异步执行(拉镜像+逐个重建可能数分钟),
|
||||||
|
// 这里发起后轮询 upgrade-status 里的进度,避免单个请求悬死(旧版同步等待被反馈"一直卡死")。
|
||||||
const upgradeAll = async () => {
|
const upgradeAll = async () => {
|
||||||
const n = upg?.outdatedCount || 0;
|
const n = upg?.outdatedCount || 0;
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: `升级全部 ${n} 个可升级实例?`,
|
title: `升级全部 ${n} 个可升级实例?`,
|
||||||
body: '将逐个拉取最新实例镜像并重建(数据保留),每个约十几秒到几分钟;期间这些实例会短暂重连。',
|
body: '后台逐个拉取最新实例镜像并重建(数据保留);期间这些实例会短暂重连,可离开本页。',
|
||||||
confirmText: '全部升级',
|
confirmText: '全部升级',
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setUpgradingAll(true);
|
setUpgradingAll(true);
|
||||||
toast('正在逐个升级实例,请勿离开…', 'info');
|
|
||||||
try {
|
try {
|
||||||
const r = await api.upgradeAllInstances();
|
const r = await api.upgradeAllInstances();
|
||||||
toast(`升级完成:成功 ${r.upgraded}${r.failed ? `、失败 ${r.failed}` : ''}`, r.failed ? 'error' : 'ok');
|
if (!r.started) {
|
||||||
|
toast('所有实例已是最新镜像', 'ok');
|
||||||
|
setUpgradingAll(false);
|
||||||
|
await load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast(`已开始升级 ${r.total} 个实例(后台进行)…`, 'info');
|
||||||
|
// 轮询进度直到完成(3s 一次;异常网络下最多轮 30 分钟兜底退出)
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
await new Promise((res) => setTimeout(res, 3000));
|
||||||
|
try {
|
||||||
|
const s = await api.upgradeStatus();
|
||||||
|
const p = s.upgradeAll;
|
||||||
|
if (p.running) {
|
||||||
|
setUpgProgress(`${p.done}/${p.total}${p.phase ? ` · ${p.phase}` : ''}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
toast(
|
||||||
|
`升级完成:成功 ${p.total - p.failed}${p.failed ? `、失败 ${p.failed}(看面板日志)` : ''}`,
|
||||||
|
p.failed ? 'error' : 'ok',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
/* 面板短暂不可达(不影响后台任务),继续轮询 */
|
||||||
|
}
|
||||||
|
}
|
||||||
await load();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(e.message || '升级失败', 'error');
|
toast(e.message || '升级失败', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setUpgradingAll(false);
|
setUpgradingAll(false);
|
||||||
|
setUpgProgress('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -485,7 +512,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
|||||||
<span className="muted small">(更新面板不会自动升级实例,二者是不同镜像)</span>
|
<span className="muted small">(更新面板不会自动升级实例,二者是不同镜像)</span>
|
||||||
</span>
|
</span>
|
||||||
<button className="btn btn-primary s-btn" disabled={upgradingAll} onClick={upgradeAll}>
|
<button className="btn btn-primary s-btn" disabled={upgradingAll} onClick={upgradeAll}>
|
||||||
{upgradingAll ? '升级中…请稍候' : '一键升级全部实例'}
|
{upgradingAll ? `升级中 ${upgProgress || '…'}` : '一键升级全部实例'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user