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:
Gloridust
2026-06-15 00:48:36 +08:00
Unverified
parent 0abb5f8bdf
commit 9929a72be1
6 changed files with 392 additions and 48 deletions
+139 -46
View File
@@ -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 entryUSTAR 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> {
+58 -2
View File
@@ -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):系统信息 + 面板日志 + 各实例容器状态/持久日志/实时日志 + 全部容器清单。
// 单实例日志只记录"实例内单次日志",这里把全局 + 全部实例 + 容器层面的信息打包,便于排查
// 首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败等问题。range24h(默认)/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();
+128
View File
@@ -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 {
/* 忽略 */
}
}
// ---------- 面板级全局日志 ----------
// 同时写入持久文件并回显 stdoutdocker 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'];
}
+3
View File
@@ -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`),
+56
View File
@@ -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>
+8
View File
@@ -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;
}