From 9929a72be1b0e2c9b4f8e14b6db1ac34153a5f8d Mon Sep 17 00:00:00 2001 From: Gloridust Date: Mon, 15 Jun 2026 00:48:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(panel):=20=E5=85=A8=E5=B1=80=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=B3=BB=E7=BB=9F=20+=20=E4=B8=80=E9=94=AE=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E5=8C=85=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单实例「日志」只记录该实例日志,无从排查跨实例/容器层面的问题(首个实例创建卡死、 打开实例黑屏不可用、升级失败等)。新增面板级全局日志 + 一键诊断包: - logs.ts(新):统一持久化日志(面板数据卷,跨重建保留)。实例日志原语从 docker.ts 迁来;新增 appendPanelLog/readPanelLog、按时间裁剪 filterSince、一年保留 pruneOldLogs、 时间范围 24h/7d/30d/1y。无 docker 依赖避免循环。 - 仪表化:实例创建(含镜像拉取前后,定位首次拉取卡死)、删除、启停、重启、升级、 应用安装/更新、看门狗自愈 均写入面板全局日志(同时回显 stdout)。 - docker.ts buildDiagnostics:打包 system.txt(Docker/镜像/系统)+ panel.log + instances/.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) --- panel/server/src/docker.ts | 185 +++++++++++++++++++++++++--------- panel/server/src/index.ts | 60 ++++++++++- panel/server/src/logs.ts | 128 +++++++++++++++++++++++ panel/web/src/api.ts | 3 + panel/web/src/pages/Admin.tsx | 56 ++++++++++ panel/web/src/styles.css | 8 ++ 6 files changed, 392 insertions(+), 48 deletions(-) create mode 100644 panel/server/src/logs.ts diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index 48529f1..228146f 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -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 { } 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): Promise { + 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/.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 return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用 } -// ---------- 持久化日志(跨容器重建保留,存在面板数据卷里) ---------- -// docker logs 随容器重建(重启/升级/看门狗自愈)即丢失,看不到"上次为何重启/崩溃"。这里把 -// 重启原因 + 重建前的容器日志快照 + 生命周期事件,追加到面板数据卷的 /…/logs/.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 { diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index ebbdca8..f293afb 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -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(); diff --git a/panel/server/src/logs.ts b/panel/server/src/logs.ts new file mode 100644 index 0000000..adda344 --- /dev/null +++ b/panel/server/src/logs.ts @@ -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 = { + '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']; +} diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index 4aa3a03..b6fe466 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -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`), diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index 3c41979..77dcd74 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -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,用隐藏 触发下载(带同源 cookie),不离开页面。 + const a = document.createElement('a'); + a.href = api.diagnosticsUrl(range); + document.body.appendChild(a); + a.click(); + a.remove(); + }; + return ( + <> +
+ 诊断与日志 +
+
+ + ); +} + // 「关于」:显示真实构建版本号 + 检测新版(后台已每 6h 查 Docker Hub/GHCR;这里读缓存并可手动重查)。 function AboutSection({ isAdmin }: { isAdmin: boolean }) { const { toast } = useUI(); @@ -538,6 +593,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () + {isAdmin && } diff --git a/panel/web/src/styles.css b/panel/web/src/styles.css index e1fdb28..a45bab8 100644 --- a/panel/web/src/styles.css +++ b/panel/web/src/styles.css @@ -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; }