mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
feat(panel): 全局日志系统 + 一键诊断包导出
单实例「日志」只记录该实例日志,无从排查跨实例/容器层面的问题(首个实例创建卡死、 打开实例黑屏不可用、升级失败等)。新增面板级全局日志 + 一键诊断包: - logs.ts(新):统一持久化日志(面板数据卷,跨重建保留)。实例日志原语从 docker.ts 迁来;新增 appendPanelLog/readPanelLog、按时间裁剪 filterSince、一年保留 pruneOldLogs、 时间范围 24h/7d/30d/1y。无 docker 依赖避免循环。 - 仪表化:实例创建(含镜像拉取前后,定位首次拉取卡死)、删除、启停、重启、升级、 应用安装/更新、看门狗自愈 均写入面板全局日志(同时回显 stdout)。 - docker.ts buildDiagnostics:打包 system.txt(Docker/镜像/系统)+ panel.log + instances/<id>.log(容器状态 inspect + 持久日志 + 实时日志)+ containers.txt(全部 woc-* 容器清单,含残留),手搓多文件 tar.gz(沿用既有无依赖 tar 风格)。 - 路由:GET /api/admin/diagnostics?range=(导出 tar.gz)、GET /api/admin/panel-log?range=; 启动 + 每 24h 跑 pruneOldLogs。 - 前端:管理页「诊断与日志」区——时间范围(24h默认/7d/30d/1y) + 导出诊断包 + 查看面板日志。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+139
-46
@@ -1,6 +1,6 @@
|
||||
import { hostname } from 'node:os';
|
||||
import { existsSync, readdirSync, appendFileSync, mkdirSync, statSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { appendInstanceLog, deleteInstanceLog, appendPanelLog, readInstanceLog, readPanelLog, filterSince } from './logs.js';
|
||||
import http from 'node:http';
|
||||
import zlib from 'node:zlib';
|
||||
import Docker from 'dockerode';
|
||||
@@ -131,7 +131,17 @@ async function ensureImage(): Promise<void> {
|
||||
} catch {
|
||||
/* 本地没有,下面拉取 */
|
||||
}
|
||||
await pullImage();
|
||||
// 首次新建实例常卡在这一步(NAS 直连 docker.io 拉取超时,见 README)。这里前后都打日志:
|
||||
// 若诊断包里只见"开始拉取"而无"完成/失败",即可定位为拉取卡死。
|
||||
appendPanelLog('INFO', `本地无实例镜像 ${WECHAT_IMAGE},开始拉取(首次较慢;NAS 直连 docker.io 可能超时)…`);
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await pullImage();
|
||||
appendPanelLog('INFO', `实例镜像拉取完成 ${WECHAT_IMAGE}(耗时 ${Math.round((Date.now() - t0) / 1000)}s)`);
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `实例镜像拉取失败 ${WECHAT_IMAGE}(耗时 ${Math.round((Date.now() - t0) / 1000)}s):${e?.message || e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建并启动一个微信实例容器。若同名容器已存在则先移除(仅容器,不动卷)。
|
||||
@@ -497,6 +507,129 @@ function tarSingleFile(name: string, content: Buffer): Buffer {
|
||||
return Buffer.concat([h, content, Buffer.alloc(pad, 0), Buffer.alloc(1024, 0)]);
|
||||
}
|
||||
|
||||
// ---------- 诊断包 ----------
|
||||
// 单个 tar entry(USTAR header + 内容 + 512 对齐填充),复用与 tarSingleFile 相同的格式。
|
||||
function tarEntry(name: string, content: Buffer): Buffer {
|
||||
const h = Buffer.alloc(512, 0);
|
||||
h.write(name.slice(0, 100), 0, 'utf8');
|
||||
h.write('0000644\0', 100);
|
||||
h.write('0001750\0', 108);
|
||||
h.write('0001750\0', 116);
|
||||
h.write(content.length.toString(8).padStart(11, '0') + '\0', 124);
|
||||
h.write('00000000000\0', 136);
|
||||
h.write(' ', 148); // checksum 占位
|
||||
h.write('0', 156); // typeflag 普通文件
|
||||
h.write('ustar\0', 257);
|
||||
h.write('00', 263);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 512; i++) sum += h[i];
|
||||
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
|
||||
const pad = (512 - (content.length % 512)) % 512;
|
||||
return Buffer.concat([h, content, Buffer.alloc(pad, 0)]);
|
||||
}
|
||||
|
||||
// 多文件 tar.gz(内存构建;诊断包通常仅数 MB)。文件名用 ASCII 路径避免 utf8 超 100 字节。
|
||||
function buildTarGz(entries: { name: string; content: string | Buffer }[]): Buffer {
|
||||
const parts = entries.map((e) => tarEntry(e.name, Buffer.isBuffer(e.content) ? e.content : Buffer.from(e.content, 'utf8')));
|
||||
parts.push(Buffer.alloc(1024, 0)); // 两个空块标记归档结束
|
||||
return zlib.gzipSync(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
// 汇总诊断包:系统信息 + 面板全局日志 + 每个实例(容器状态 + 持久日志 + 实时日志)+ 全部 woc-* 容器清单。
|
||||
// 日志按 sinceMs 时间裁剪。给排查"首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败"等问题用。
|
||||
export async function buildDiagnostics(instances: Instance[], sinceMs: number, meta: Record<string, string>): Promise<Buffer> {
|
||||
const entries: { name: string; content: string | Buffer }[] = [];
|
||||
const stamp = new Date().toISOString();
|
||||
|
||||
entries.push({
|
||||
name: 'README.txt',
|
||||
content: [
|
||||
'云微 · WechatOnCloud 诊断包',
|
||||
`生成时间: ${stamp}`,
|
||||
`时间范围: 最近 ${meta.range || '24h'}`,
|
||||
'',
|
||||
'内容:',
|
||||
' system.txt 系统/Docker/镜像信息',
|
||||
' panel.log 面板全局运维日志(创建/删除/升级/启停/镜像拉取/错误)',
|
||||
' containers.txt 所有 woc-* 容器清单(含残留/未登记)',
|
||||
' instances/<id>.log 每个实例:容器状态 + 持久日志 + 实时容器日志',
|
||||
'',
|
||||
'把本压缩包发给维护者即可协助排查(不含密码/密钥等敏感信息)。',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
// 系统信息
|
||||
let sys = `生成时间: ${stamp}\n时间范围: 最近 ${meta.range || '24h'}\n\n`;
|
||||
for (const [k, v] of Object.entries(meta)) sys += `${k}: ${v}\n`;
|
||||
try {
|
||||
const ver: any = await docker.version();
|
||||
sys += `\nDocker 版本: ${ver.Version} (API ${ver.ApiVersion}, ${ver.Os}/${ver.Arch})\n`;
|
||||
} catch (e: any) {
|
||||
sys += `\nDocker 版本: 获取失败 ${e?.message || e}\n`;
|
||||
}
|
||||
try {
|
||||
const info: any = await docker.info();
|
||||
sys += `容器: ${info.Containers}(运行 ${info.ContainersRunning}) · 镜像: ${info.Images}\n`;
|
||||
sys += `内核: ${info.KernelVersion} · OS: ${info.OperatingSystem} · 架构: ${info.Architecture}\n`;
|
||||
sys += `CPU: ${info.NCPU} 核 · 内存: ${(info.MemTotal / 1073741824).toFixed(1)} GiB\n`;
|
||||
if (Array.isArray(info.Warnings) && info.Warnings.length) sys += `Docker 警告: ${info.Warnings.join('; ')}\n`;
|
||||
} catch (e: any) {
|
||||
sys += `Docker info: 获取失败 ${e?.message || e}\n`;
|
||||
}
|
||||
try {
|
||||
const img: any = await docker.getImage(WECHAT_IMAGE).inspect();
|
||||
sys += `\n实例镜像 ${WECHAT_IMAGE}: ${String(img.Id).slice(0, 19)} · 创建 ${img.Created}\n`;
|
||||
} catch {
|
||||
sys += `\n实例镜像 ${WECHAT_IMAGE}: 本地不存在(首次新建实例需联网拉取,可能在此卡住)\n`;
|
||||
}
|
||||
sys += `\n实例数: ${instances.length}\n`;
|
||||
entries.push({ name: 'system.txt', content: sys });
|
||||
|
||||
// 面板全局日志(按范围裁剪)
|
||||
entries.push({ name: 'panel.log', content: filterSince(readPanelLog(), sinceMs) || '(无面板日志)' });
|
||||
|
||||
// 每个实例
|
||||
for (const inst of instances) {
|
||||
let c = `实例: ${inst.name}\nID: ${inst.id}\n容器: ${inst.containerName}\n类型: ${instanceAppType(inst)}\n数据卷: ${inst.volumeName}\n创建: ${inst.createdAt}\n\n`;
|
||||
try {
|
||||
const info: any = await docker.getContainer(inst.containerName).inspect();
|
||||
const s = info.State || {};
|
||||
c += `===== 容器状态 =====\n运行: ${s.Running} · 状态: ${s.Status} · 退出码: ${s.ExitCode}\n`;
|
||||
c += `OOMKilled: ${s.OOMKilled} · 重启次数: ${info.RestartCount} · 启动于: ${s.StartedAt}\n`;
|
||||
if (s.Error) c += `错误: ${s.Error}\n`;
|
||||
c += `镜像: ${String(info.Image).slice(0, 19)} · 健康: ${s.Health?.Status ?? 'n/a'}\n\n`;
|
||||
} catch (e: any) {
|
||||
c += `===== 容器状态 =====\n无法读取(容器可能未创建/已删除):${e?.message || e}\n\n`;
|
||||
}
|
||||
c += `===== 持久化日志(最近 ${meta.range || '24h'}) =====\n${filterSince(readInstanceLog(inst.id), sinceMs) || '(无)'}\n\n`;
|
||||
try {
|
||||
c += `===== 本次容器日志(实时 tail 300) =====\n${(await instanceLogs(inst, 300)).trimEnd() || '(无)'}\n`;
|
||||
} catch (e: any) {
|
||||
c += `===== 本次容器日志 =====\n获取失败:${e?.message || e}\n`;
|
||||
}
|
||||
entries.push({ name: `instances/${inst.id}.log`, content: c });
|
||||
}
|
||||
|
||||
// 全部 woc-* 容器清单(含未登记/残留,用于诊断"首次创建失败遗留")
|
||||
try {
|
||||
const all = await docker.listContainers({ all: true });
|
||||
const known = new Set(instances.map((i) => i.containerName));
|
||||
let txt = '所有 woc-* 容器:\n\n';
|
||||
for (const ct of all) {
|
||||
const names = (ct.Names || []).map((n: string) => n.replace(/^\//, ''));
|
||||
if (!names.some((n) => n.startsWith('woc-'))) continue;
|
||||
const nm = names.join(',');
|
||||
const tag = nm.includes('woc-panel') ? '面板' : known.has(nm) ? '已登记实例' : '未登记/残留';
|
||||
txt += `[${tag}] ${nm} · ${ct.State}/${ct.Status} · ${ct.Image}\n`;
|
||||
}
|
||||
entries.push({ name: 'containers.txt', content: txt });
|
||||
} catch (e: any) {
|
||||
entries.push({ name: 'containers.txt', content: '获取失败:' + (e?.message || e) });
|
||||
}
|
||||
|
||||
return buildTarGz(entries);
|
||||
}
|
||||
|
||||
// 校验文件名为安全 basename(防路径穿越)。
|
||||
function safeName(name: string): boolean {
|
||||
return !!name && name.length <= 200 && !name.includes('/') && !name.includes('\0') && name !== '.' && name !== '..';
|
||||
@@ -586,49 +719,9 @@ export async function instanceLogs(inst: Instance, tail = 600): Promise<string>
|
||||
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
|
||||
}
|
||||
|
||||
// ---------- 持久化日志(跨容器重建保留,存在面板数据卷里) ----------
|
||||
// docker logs 随容器重建(重启/升级/看门狗自愈)即丢失,看不到"上次为何重启/崩溃"。这里把
|
||||
// 重启原因 + 重建前的容器日志快照 + 生命周期事件,追加到面板数据卷的 /…/logs/<id>.log,跨重建保留。
|
||||
// 与 store.ts 的 accounts.json 同目录(面板数据卷,宿主 ./data-panel 持久化)。fallback 须与 store.ts 一致。
|
||||
const LOG_DIR = `${dirname(process.env.PANEL_DATA || '/data/panel/accounts.json')}/logs`;
|
||||
const LOG_CAP = 400 * 1024; // 每实例日志上限 ~400KB,超限截掉前半保留最近
|
||||
|
||||
function logPath(id: string): string {
|
||||
return `${LOG_DIR}/${id}.log`;
|
||||
}
|
||||
|
||||
export function appendInstanceLog(id: string, line: string): void {
|
||||
if (!/^[0-9a-f]{1,32}$/.test(id)) return; // 防路径注入;实例 id 为十六进制
|
||||
try {
|
||||
mkdirSync(LOG_DIR, { recursive: true });
|
||||
const p = logPath(id);
|
||||
appendFileSync(p, `[${new Date().toISOString()}] ${line}\n`);
|
||||
const sz = statSync(p).size;
|
||||
if (sz > LOG_CAP) writeFileSync(p, readFileSync(p).subarray(sz - Math.floor(LOG_CAP / 2)));
|
||||
} catch {
|
||||
/* 写持久日志失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
export function readInstanceLog(id: string): string {
|
||||
if (!/^[0-9a-f]{1,32}$/.test(id)) return '';
|
||||
try {
|
||||
const p = logPath(id);
|
||||
return existsSync(p) ? readFileSync(p, 'utf8') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 实例彻底删除(连数据卷一并清除)时,顺手删掉它的持久日志文件,避免遗留孤儿。
|
||||
export function deleteInstanceLog(id: string): void {
|
||||
if (!/^[0-9a-f]{1,32}$/.test(id)) return;
|
||||
try {
|
||||
rmSync(logPath(id), { force: true });
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
// ---------- 持久化日志 ----------
|
||||
// 日志原语(appendInstanceLog / readInstanceLog / deleteInstanceLog / appendPanelLog 等)已抽到 logs.ts
|
||||
// (无 docker 依赖,避免循环)。这里只保留需要 docker 的快照能力。
|
||||
|
||||
// 把"即将被删/重建"的容器最后日志快照进持久日志(否则随容器删除丢失)。
|
||||
export async function snapshotContainerLog(inst: Instance, reason: string): Promise<void> {
|
||||
|
||||
@@ -50,8 +50,7 @@ import {
|
||||
downloadFromInstance,
|
||||
deleteInstanceFile,
|
||||
instanceLogs,
|
||||
appendInstanceLog,
|
||||
readInstanceLog,
|
||||
buildDiagnostics,
|
||||
typeInInstance,
|
||||
keyInInstance,
|
||||
listOrphanVolumes,
|
||||
@@ -74,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 { appendInstanceLog, readInstanceLog, appendPanelLog, readPanelLog, pruneOldLogs, filterSince, rangeToMs, DIAG_RANGES } from './logs.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -307,12 +307,19 @@ app.post('/api/admin/instances', async (req, reply) => {
|
||||
reuseVolumeName = reuseVolume;
|
||||
}
|
||||
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName, type);
|
||||
appendPanelLog(
|
||||
'INFO',
|
||||
`创建实例「${inst.name}」(${type}, id=${inst.id}) by ${admin.username}${reuseVolumeName ? ` · 复用卷 ${reuseVolumeName}` : ''} → 开始创建容器(镜像缺失会自动拉取,首次较慢)`,
|
||||
);
|
||||
appendInstanceLog(inst.id, `实例创建(${type})by ${admin.username}`);
|
||||
try {
|
||||
await runInstance(inst);
|
||||
} catch (e: any) {
|
||||
removeInstanceRecord(inst.id); // 容器起不来则回滚登记
|
||||
appendPanelLog('ERROR', `创建实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '创建容器失败:' + (e?.message || e) });
|
||||
}
|
||||
appendPanelLog('INFO', `创建实例「${inst.name}」(id=${inst.id}) 成功`);
|
||||
return { instance: publicInstance(inst) };
|
||||
});
|
||||
|
||||
@@ -449,6 +456,7 @@ app.delete('/api/admin/instances/:id', async (req, reply) => {
|
||||
const purge = (req.query as any)?.purge === '1' || (req.query as any)?.purge === 'true';
|
||||
const inst = findInstance(id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
appendPanelLog('INFO', `删除实例「${inst.name}」(id=${id})${purge ? ' · 同时清除数据卷' : ' · 保留数据卷'}`);
|
||||
await removeInstanceContainer(inst, purge);
|
||||
removeInstanceRecord(id);
|
||||
controlHolders.delete(id);
|
||||
@@ -484,8 +492,10 @@ app.post('/api/admin/instances/:id/start', async (req, reply) => {
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await ensureRunning(inst);
|
||||
appendPanelLog('INFO', `启动实例「${inst.name}」(id=${inst.id})`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `启动实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '启动失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -497,8 +507,10 @@ app.post('/api/admin/instances/:id/stop', async (req, reply) => {
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
appendPanelLog('INFO', `停止实例「${inst.name}」(id=${inst.id})`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `停止实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '停止失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -509,9 +521,11 @@ app.post('/api/admin/instances/:id/restart', async (req, reply) => {
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
appendPanelLog('INFO', `重启实例「${inst.name}」(id=${inst.id})`);
|
||||
await runInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `重启实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '重启失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -523,9 +537,12 @@ app.post('/api/admin/instances/:id/upgrade', async (req, reply) => {
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}):拉取最新镜像后重建`);
|
||||
await upgradeInstance(inst);
|
||||
appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}) 完成`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '升级失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -701,6 +718,37 @@ app.get('/api/admin/instances/:id/logs', async (req, reply) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ---------- 全局日志 / 诊断包(仅管理员)----------
|
||||
// 面板全局运维日志(创建/删除/升级/启停/镜像拉取/错误等跨实例事件),可按时间范围裁剪。
|
||||
app.get('/api/admin/panel-log', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
const since = Date.now() - rangeToMs((req.query as any)?.range);
|
||||
const text = filterSince(readPanelLog(), since).trimEnd();
|
||||
return reply.send(text || '(暂无面板日志)');
|
||||
});
|
||||
|
||||
// 一键导出诊断包(tar.gz):系统信息 + 面板日志 + 各实例容器状态/持久日志/实时日志 + 全部容器清单。
|
||||
// 单实例日志只记录"实例内单次日志",这里把全局 + 全部实例 + 容器层面的信息打包,便于排查
|
||||
// 首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败等问题。range:24h(默认)/7d/30d/1y。
|
||||
app.get('/api/admin/diagnostics', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const range = ((req.query as any)?.range as string) || '24h';
|
||||
if (!DIAG_RANGES[range]) return reply.code(400).send({ error: '时间范围非法(24h/7d/30d/1y)' });
|
||||
const since = Date.now() - rangeToMs(range);
|
||||
try {
|
||||
const buf = await buildDiagnostics(listInstances(), since, { range, 面板版本: CURRENT_VERSION });
|
||||
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
||||
reply.header('content-type', 'application/gzip');
|
||||
reply.header('content-disposition', `attachment; filename="woc-diag-${range}-${stamp}.tar.gz"`);
|
||||
appendPanelLog('INFO', `导出诊断包(范围 ${range},${buf.length} 字节)`);
|
||||
return reply.send(buf);
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `导出诊断包失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '生成诊断包失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 数据卷管理(仅管理员):浏览/上传/解压/下载/改名/移动/删除 + 整卷备份/恢复 ----------
|
||||
// 数据卷 = 容器 /config,含微信完整会话与加密聊天库 → 仅 admin 可见可用(admin 本就有 docker.sock=宿主 root,
|
||||
// 不新增风险;子账号永不可达)。
|
||||
@@ -854,8 +902,10 @@ async function triggerInstanceWechat(id: string, cmd: 'install' | 'update', repl
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await triggerWechat(inst, cmd);
|
||||
appendPanelLog('INFO', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '下载安装' : '更新'}应用`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '安装' : '更新'}失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '无法触发安装:' + (e?.message || e) });
|
||||
}
|
||||
}
|
||||
@@ -1034,12 +1084,14 @@ if (WATCHDOG_ENABLED) {
|
||||
recovering.add(inst.id);
|
||||
app.log.warn(`[watchdog] ${inst.containerName} ${detail}`);
|
||||
appendInstanceLog(inst.id, `[看门狗] 自愈重启(${reason}):${detail}`);
|
||||
appendPanelLog('WARN', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈重启(${reason}):${detail}`);
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
await runInstance(inst);
|
||||
healthFails.delete(inst.id);
|
||||
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason})`);
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈失败(${reason}):${e?.message || e}`);
|
||||
app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}): ${e?.message || e}`);
|
||||
} finally {
|
||||
recovering.delete(inst.id);
|
||||
@@ -1098,4 +1150,8 @@ if (WATCHDOG_ENABLED) {
|
||||
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)· 版本 ${CURRENT_VERSION}`);
|
||||
appendPanelLog('INFO', `面板启动 · 版本 ${CURRENT_VERSION} · 监听 ${HOST}:${PORT}`);
|
||||
startUpdateChecker(); // 后台检测新版(best-effort,失败静默)
|
||||
// 日志保留期清理:启动后跑一次 + 每 24h 一次,删除超过一年的日志行(unref 不阻止退出)。
|
||||
pruneOldLogs();
|
||||
setInterval(() => pruneOldLogs(), 24 * 60 * 60 * 1000).unref();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// 全局持久化日志(存在面板数据卷 /…/logs/,宿主 ./data-panel 持久保留,跨容器/面板重建不丢)。
|
||||
// 两类日志:
|
||||
// _panel.log 面板级运维事件(实例创建/删除/升级/启停、镜像拉取、错误等跨实例的全局动作)
|
||||
// <实例id>.log 单实例生命周期 + 重启原因 + 重建前容器日志快照
|
||||
// 单文件按大小封顶(超限截掉前半保留最近),并按「一年保留」定期清理过期行。
|
||||
// 本模块不依赖 docker,避免与 docker.ts 形成循环依赖(docker.ts/index.ts 反过来引用本模块)。
|
||||
|
||||
import { appendFileSync, mkdirSync, statSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
// 与 store.ts 的 accounts.json 同目录。fallback 须与 store.ts 一致。
|
||||
export const LOG_DIR = `${dirname(process.env.PANEL_DATA || '/data/panel/accounts.json')}/logs`;
|
||||
const PER_FILE_CAP = 512 * 1024; // 单文件 ~512KB,超限截掉前半保留最近
|
||||
const RETENTION_MS = 365 * 24 * 60 * 60 * 1000; // 日志保留一年,更早的自动清理
|
||||
const PANEL_LOG = `${LOG_DIR}/_panel.log`;
|
||||
const INSTANCE_ID_RE = /^[0-9a-f]{1,32}$/; // 实例 id 为十六进制;校验防路径注入
|
||||
|
||||
export type LogLevel = 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function appendTo(file: string, line: string): void {
|
||||
try {
|
||||
mkdirSync(LOG_DIR, { recursive: true });
|
||||
appendFileSync(file, line.endsWith('\n') ? line : line + '\n');
|
||||
const sz = statSync(file).size;
|
||||
if (sz > PER_FILE_CAP) writeFileSync(file, readFileSync(file).subarray(sz - Math.floor(PER_FILE_CAP / 2)));
|
||||
} catch {
|
||||
/* 写日志失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
function instanceLogPath(id: string): string {
|
||||
return `${LOG_DIR}/${id}.log`;
|
||||
}
|
||||
|
||||
// ---------- 单实例日志 ----------
|
||||
export function appendInstanceLog(id: string, line: string): void {
|
||||
if (!INSTANCE_ID_RE.test(id)) return;
|
||||
appendTo(instanceLogPath(id), `[${nowIso()}] ${line}`);
|
||||
}
|
||||
|
||||
export function readInstanceLog(id: string): string {
|
||||
if (!INSTANCE_ID_RE.test(id)) return '';
|
||||
try {
|
||||
const p = instanceLogPath(id);
|
||||
return existsSync(p) ? readFileSync(p, 'utf8') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 实例彻底删除(连数据卷一并清除)时,顺手删掉它的持久日志文件,避免遗留孤儿。
|
||||
export function deleteInstanceLog(id: string): void {
|
||||
if (!INSTANCE_ID_RE.test(id)) return;
|
||||
try {
|
||||
rmSync(instanceLogPath(id), { force: true });
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 面板级全局日志 ----------
|
||||
// 同时写入持久文件并回显 stdout(docker logs woc-panel 仍可见)。运维动作统一走这里,便于诊断包汇总。
|
||||
export function appendPanelLog(level: LogLevel, message: string): void {
|
||||
appendTo(PANEL_LOG, `[${nowIso()}] [${level}] ${message}`);
|
||||
const c = level === 'ERROR' ? console.error : level === 'WARN' ? console.warn : console.log;
|
||||
c(`[panel] ${message}`);
|
||||
}
|
||||
|
||||
export function readPanelLog(): string {
|
||||
try {
|
||||
return existsSync(PANEL_LOG) ? readFileSync(PANEL_LOG, 'utf8') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 时间裁剪 / 保留期清理 ----------
|
||||
// 保留行首 [ISO时间] >= sinceMs 的行;无法解析时间戳的行跟随上一条的保留状态(多行块整体保留)。
|
||||
export function filterSince(text: string, sinceMs: number): string {
|
||||
if (!text) return '';
|
||||
const out: string[] = [];
|
||||
let keeping = false;
|
||||
for (const ln of text.split('\n')) {
|
||||
const m = /^\[(\d{4}-\d\d-\d\dT[\d:.]+Z)\]/.exec(ln);
|
||||
if (m) {
|
||||
const t = Date.parse(m[1]);
|
||||
if (Number.isFinite(t)) keeping = t >= sinceMs;
|
||||
}
|
||||
if (keeping) out.push(ln);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// 清理所有日志文件中早于一年的行;整文件均过期则删除。每日定时调用。
|
||||
export function pruneOldLogs(): void {
|
||||
const cutoff = Date.now() - RETENTION_MS;
|
||||
try {
|
||||
if (!existsSync(LOG_DIR)) return;
|
||||
for (const f of readdirSync(LOG_DIR)) {
|
||||
if (!f.endsWith('.log')) continue;
|
||||
const p = `${LOG_DIR}/${f}`;
|
||||
try {
|
||||
const kept = filterSince(readFileSync(p, 'utf8'), cutoff);
|
||||
if (kept.trim()) writeFileSync(p, kept.endsWith('\n') ? kept : kept + '\n');
|
||||
else rmSync(p, { force: true });
|
||||
} catch {
|
||||
/* 单文件失败不影响其它 */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// 诊断包可选时间范围(→ 毫秒)。默认 24h。
|
||||
export const DIAG_RANGES: Record<string, number> = {
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||
'1y': 365 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
export function rangeToMs(range: string | undefined): number {
|
||||
return DIAG_RANGES[range ?? '24h'] ?? DIAG_RANGES['24h'];
|
||||
}
|
||||
@@ -184,6 +184,9 @@ export const api = {
|
||||
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
|
||||
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
|
||||
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
|
||||
// 全局日志 / 诊断包(范围 24h/7d/30d/1y)
|
||||
diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`,
|
||||
panelLogUrl: (range: string) => `/api/admin/panel-log?range=${encodeURIComponent(range)}`,
|
||||
|
||||
// 文件中转
|
||||
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
|
||||
|
||||
@@ -82,6 +82,61 @@ function EmptyState({ icon, title, sub, action }: { icon: string; title: string;
|
||||
|
||||
const RELEASES_URL = 'https://github.com/Gloridust/WechatOnCloud/releases';
|
||||
|
||||
const DIAG_RANGE_OPTIONS = [
|
||||
{ key: '24h', label: '24 小时' },
|
||||
{ key: '7d', label: '7 天' },
|
||||
{ key: '30d', label: '30 天' },
|
||||
{ key: '1y', label: '1 年' },
|
||||
];
|
||||
|
||||
// 「诊断与日志」(仅管理员):单实例「日志」只记录该实例日志;这里一键打包全局——系统信息 +
|
||||
// 面板运维日志 + 全部实例容器状态/日志 + 容器清单,便于排查部署/创建卡死/黑屏不可用等问题。
|
||||
function DiagnosticsSection() {
|
||||
const [range, setRange] = useState('24h');
|
||||
const exportBundle = () => {
|
||||
// tar.gz 带 content-disposition: attachment,用隐藏 <a> 触发下载(带同源 cookie),不离开页面。
|
||||
const a = document.createElement('a');
|
||||
a.href = api.diagnosticsUrl(range);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="section-row" style={{ marginTop: 22 }}>
|
||||
<span className="section-title">诊断与日志</span>
|
||||
</div>
|
||||
<div className="inst-grid">
|
||||
<div className="inst-card">
|
||||
<div className="inst-head">
|
||||
<span className="inst-name">导出诊断包</span>
|
||||
</div>
|
||||
<div className="inst-sub">打包系统/Docker 信息 + 面板全局日志 + 各实例容器状态与日志 + 容器清单,用于排查部署、创建卡死、黑屏不可用、升级失败等问题。</div>
|
||||
<div className="diag-range">
|
||||
<span className="field-label">时间范围</span>
|
||||
<div className="chip-row">
|
||||
{DIAG_RANGE_OPTIONS.map((r) => (
|
||||
<button key={r.key} className={'chip-toggle' + (range === r.key ? ' on' : '')} onClick={() => setRange(r.key)}>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="inst-actions">
|
||||
<button className="btn btn-primary" onClick={exportBundle}>
|
||||
导出诊断包 (.tar.gz)
|
||||
</button>
|
||||
<a className="btn" href={api.panelLogUrl(range)} target="_blank" rel="noreferrer">
|
||||
查看面板日志
|
||||
</a>
|
||||
</div>
|
||||
<div className="muted small ver-checked">导出当前选定时间范围内的日志。超过一年的日志会自动清理;诊断包不含密码/密钥等敏感信息。</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 「关于」:显示真实构建版本号 + 检测新版(后台已每 6h 查 Docker Hub/GHCR;这里读缓存并可手动重查)。
|
||||
function AboutSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { toast } = useUI();
|
||||
@@ -538,6 +593,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && <DiagnosticsSection />}
|
||||
<AboutSection isAdmin={isAdmin} />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1047,6 +1047,14 @@ button {
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* 诊断包时间范围选择行 */
|
||||
.diag-range {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.diag-range .field-label {
|
||||
display: block;
|
||||
margin: 0 2px 8px;
|
||||
}
|
||||
.chip-row-pick {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user