diff --git a/.env.example b/.env.example index dbf3c0f..895b285 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,34 @@ WOC_HTTP_PORT=36080 # b) 显式:在下面列出设备(逗号分隔),并可删掉那条 /host-dev 挂载。 # 留空 = 不映射摄像头(音频/麦克风不受影响)。 WOC_VIDEO_DEVICES= + +# ── 实例资源 / 稳定性 ─────────────────────────────────────── +# GPU 硬件编码:baseimage 检测到 /dev/dri/renderD* 时会给 Xvnc 加 -hw3d(GPU 加速编码)。 +# 在 WSL2 / 虚拟 GPU(如 Docker Desktop on Windows)下,该路径会导致 Xvnc 内存持续膨胀 +# (实测 21 小时涨到 ~9GB)。本项目已强制软件渲染(LIBGL_ALWAYS_SOFTWARE=1),hw3d 对微信 +# 这类静态界面收益甚微,故默认关闭。仅在你有真实可用的 GPU 且确认无内存问题时再设为 1 启用。 +WOC_ENABLE_GPU= + +# 每个微信实例容器的内存上限(GiB)。默认空 = 不限制(与旧版一致)。 +# 作为兜底:万一某进程异常增长,命中上限时容器内 OOM 杀进程、由 s6 自动重启 VNC, +# 避免拖垮整台宿主。常规使用单实例约需 1~1.5GiB,设 3~4 较稳妥。 +WOC_INSTANCE_MEM_GB= + +# ── 自愈 watchdog(应对 KasmVNC/Xvnc 长跑内存泄漏) ─────────── +# 实测 Xvnc 长跑 24h 可膨胀到 ~9GiB,原因在 KasmVNC/Xvnc 自身的 framebuffer / 软渲染 cache +# 累积,不归本项目控制。面板内置一个 watchdog:周期性检查每个 running 实例的 working set +# 内存(= docker stats 显示值),按两档阈值(这里设全局默认;每个实例可在「管理 → 实例卡片 +# → 安全」按钮里单独覆盖)触发主动 stop + run(聊天数据在卷里、登录态本来也会定期失效, +# 可控时机自愈优于撑到 OOM): +# +# soft 超过且【当前没人在远程会话】才重启 → 柔和自愈,不打扰使用者 +# hard 超过即重启(无视会话),防止 OOM 拖垮宿主 +# +# WOC_INSTANCE_MEM_SOFT_MB soft 阈值(MiB),默认 1500 +# WOC_INSTANCE_MEM_HARD_MB hard 阈值(MiB),默认 2500(兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB) +# WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒,默认 300(5 分钟);最小 60;0 = 关闭整个 watchdog +# +# 调参建议:日常活跃单实例约 1500 MiB;soft 应略高于此(如 2000);hard 远低于宿主可用内存。 +WOC_INSTANCE_MEM_SOFT_MB=1500 +WOC_INSTANCE_MEM_HARD_MB=2500 +WOC_WATCHDOG_INTERVAL_SEC=300 diff --git a/docker-compose.yml b/docker-compose.yml index 68ee8c7..c23e2ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,13 @@ services: # 摄像头直通:逗号分隔的宿主视频设备(如 /dev/video0)。留空则自动探测(见下方 /host-dev 挂载)或禁用摄像头。 # 启用前需在宿主加载 v4l2loopback 内核模块。详见 .env.example。 - WOC_VIDEO_DEVICES=${WOC_VIDEO_DEVICES:-} + # 实例资源相关(见 .env.example):GPU 硬件编码默认关闭(避免 WSL2/虚拟 GPU 下 Xvnc 内存暴涨); + # 可选给每个实例设内存上限(GiB),0=不限制;watchdog 双阈值(soft 柔和自愈 / hard 强制重启)+ 巡检间隔。 + - WOC_ENABLE_GPU=${WOC_ENABLE_GPU:-} + - WOC_INSTANCE_MEM_GB=${WOC_INSTANCE_MEM_GB:-} + - WOC_INSTANCE_MEM_SOFT_MB=${WOC_INSTANCE_MEM_SOFT_MB:-1500} + - WOC_INSTANCE_MEM_HARD_MB=${WOC_INSTANCE_MEM_HARD_MB:-2500} + - WOC_WATCHDOG_INTERVAL_SEC=${WOC_WATCHDOG_INTERVAL_SEC:-300} # 面板首个管理员账号(仅首次启动、无账号文件时写入;务必改掉默认密码) - PANEL_ADMIN_USER=${WOC_USER:-admin} - PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat} diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index 0ac017d..cd704a2 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -9,6 +9,17 @@ const PGID = process.env.PGID || '1000'; const TZ = process.env.TZ || 'Asia/Shanghai'; const SHM_SIZE = 1024 * 1024 * 1024; // 1gb +// 默认关闭 KasmVNC 的 GPU 硬件编码(baseimage 检测到 /dev/dri/renderD* 时会给 Xvnc 加 -hw3d): +// 在 WSL2 / 虚拟 GPU 环境下该路径会导致 Xvnc 内存持续膨胀(实测反馈 21h 涨到 ~9GB)。 +// 我们已设 LIBGL_ALWAYS_SOFTWARE=1 走软件渲染,hw3d 对微信这类静态界面收益甚微。 +// 真实可用 GPU 想启用硬件编码:面板侧设 WOC_ENABLE_GPU=1。 +const ENABLE_GPU = process.env.WOC_ENABLE_GPU === '1'; + +// 可选:给每个实例容器设内存上限(GiB),作为 Xvnc 等异常增长时的兜底,避免拖垮宿主。 +// 默认 0 = 不限制(保持原行为)。命中上限时容器内 OOM 杀进程、由 s6 自动重启 VNC。 +const INSTANCE_MEM_GB = Number(process.env.WOC_INSTANCE_MEM_GB) || 0; +const INSTANCE_MEM = INSTANCE_MEM_GB > 0 ? Math.floor(INSTANCE_MEM_GB * 1024 * 1024 * 1024) : 0; + const docker = new Docker(); // 默认连 /var/run/docker.sock // 面板自身所在的 docker 网络名;新实例都 attach 到它,便于按容器名互访。 @@ -58,13 +69,16 @@ function videoDevices(): string[] { } function envList(inst: Instance): string[] { - return [ + const env = [ `PUID=${PUID}`, `PGID=${PGID}`, `TZ=${TZ}`, `CUSTOM_USER=${inst.kasmUser}`, `PASSWORD=${inst.kasmPassword}`, ]; + // baseimage 仅检查该变量是否「已设置」(值无关),设上即不再给 Xvnc 加 -hw3d。 + if (!ENABLE_GPU) env.push('DISABLE_DRI=1'); + return env; } // 确保微信镜像在本地存在;缺失则从 GHCR 拉取(首次新建实例时镜像通常还没拉过)。 @@ -98,6 +112,10 @@ export async function runInstance(inst: Instance): Promise { ShmSize: SHM_SIZE, RestartPolicy: { Name: 'unless-stopped' }, }; + if (INSTANCE_MEM > 0) { + hostConfig.Memory = INSTANCE_MEM; + hostConfig.MemorySwap = INSTANCE_MEM; // 禁止 swap 膨胀:限制即为硬上限 + } if (vids.length) { hostConfig.Devices = vids.map((d) => ({ PathOnHost: d, PathInContainer: d, CgroupPermissions: 'rwm' })); hostConfig.GroupAdd = ['video']; // 让容器内 abc 用户能访问 /dev/videoN @@ -161,6 +179,48 @@ export async function removeInstance(inst: Instance, purgeVolume: boolean): Prom } } +// 列出"未被任何实例引用的 woc-data-* 数据卷"。删除实例时默认保留卷(聊天记录),但 panel 里没有 +// UI 能看到这些孤儿卷;本接口让管理员可显式:在新建实例时复用旧卷继承聊天记录,或彻底删除。 +// 仅看名字前缀 + docker 卷视角,不读卷内文件(避免提权访问)。 +export async function listOrphanVolumes(referencedVolumes: Set): Promise< + Array<{ name: string; createdAt?: string; sizeBytes?: number }> +> { + const { Volumes } = (await (docker as any).listVolumes()) || { Volumes: [] }; + if (!Array.isArray(Volumes)) return []; + return Volumes + .filter((v: any) => typeof v?.Name === 'string' && v.Name.startsWith('woc-data-') && !referencedVolumes.has(v.Name)) + .map((v: any) => ({ + name: v.Name, + createdAt: v.CreatedAt, + // UsageData 仅在 docker engine 启用 -v size=true 时返回,常见情况下没有;缺失就不展示,避免一次 inspect 风暴 + sizeBytes: typeof v?.UsageData?.Size === 'number' && v.UsageData.Size >= 0 ? v.UsageData.Size : undefined, + })) + .sort((a, b) => (a.createdAt && b.createdAt ? (a.createdAt < b.createdAt ? 1 : -1) : 0)); +} + +// 显式删除一个数据卷(管理员清理孤儿卷用)。调用方负责确认它不被现存实例引用。 +export async function removeVolume(name: string): Promise { + await docker.getVolume(name).remove({ force: true } as any); +} + +// 取实例容器的"working set"内存(MB):等同 docker stats 显示值 = usage - inactive_file。 +// 用于 watchdog 检测 KasmVNC/Xvnc 长跑泄漏(21 小时可涨到 ~9 GiB),无法读取时返回 0(视为"暂未知", +// 不触发自愈,避免容器刚启动 stats 不可用就被误杀)。一次性 stats、不订阅 stream。 +export async function instanceMemoryMB(inst: Instance): Promise { + try { + const c = docker.getContainer(inst.containerName); + const s: any = await c.stats({ stream: false } as any); + const usage = Number(s?.memory_stats?.usage) || 0; + const inactive = Number( + s?.memory_stats?.stats?.inactive_file ?? s?.memory_stats?.stats?.total_inactive_file, + ) || 0; + const bytes = Math.max(0, usage - inactive); + return Math.round(bytes / 1024 / 1024); + } catch { + return 0; + } +} + export async function instanceRuntime(inst: Instance): Promise { try { const info = await docker.getContainer(inst.containerName).inspect(); diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 0c38c89..908da35 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -20,6 +20,7 @@ import { setUserInstances, listInstances, findInstance, + setInstanceMemLimits, userInstances, userCanAccess, createInstance, @@ -47,6 +48,9 @@ import { deleteInstanceFile, instanceLogs, typeInInstance, + listOrphanVolumes, + removeVolume, + instanceMemoryMB, } from './docker.js'; import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js'; @@ -229,12 +233,23 @@ app.get('/api/instances', async (req, reply) => { app.post('/api/admin/instances', async (req, reply) => { const admin = requireAdmin(req, reply); if (!admin) return; - const { name } = (req.body as any) ?? {}; + const { name, reuseVolume } = (req.body as any) ?? {}; const allowedUserIds = Array.isArray((req.body as any)?.allowedUserIds) ? (req.body as any).allowedUserIds : []; if (!name || String(name).trim().length === 0 || String(name).length > 30) { return reply.code(400).send({ error: '实例名称为 1-30 个字符' }); } - const inst = createInstance(String(name), admin.id, allowedUserIds); + // 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。 + let reuseVolumeName: string | undefined; + if (reuseVolume) { + if (typeof reuseVolume !== 'string' || !/^woc-data-[0-9a-zA-Z._-]{1,64}$/.test(reuseVolume)) { + return reply.code(400).send({ error: '复用卷名不合法' }); + } + if (listInstances().some((i) => i.volumeName === reuseVolume)) { + return reply.code(409).send({ error: '该数据卷已被另一个实例占用' }); + } + reuseVolumeName = reuseVolume; + } + const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName); try { await runInstance(inst); } catch (e: any) { @@ -244,6 +259,88 @@ app.post('/api/admin/instances', async (req, reply) => { return { instance: publicInstance(inst) }; }); +// 列出"未被任何实例引用的 woc-data-* 数据卷"。删除实例时默认保留卷(聊天记录),但 panel 里 +// 看不到这些孤儿卷;本接口让管理员在新建实例时复用旧卷(同微信号扫码可继承聊天记录), +// 或在不需要时彻底删除。 +app.get('/api/admin/orphan-volumes', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const referenced = new Set(listInstances().map((i) => i.volumeName)); + try { + const volumes = await listOrphanVolumes(referenced); + return { volumes }; + } catch (e: any) { + return reply.code(500).send({ error: e?.message || '读取数据卷失败' }); + } +}); + +// 显式删除一个未使用的数据卷。被现存实例占用时拒绝(避免误删聊天记录)。 +app.delete('/api/admin/orphan-volumes/:name', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const name = (req.params as any).name; + if (!name || typeof name !== 'string' || !name.startsWith('woc-data-')) { + return reply.code(400).send({ error: '卷名不合法' }); + } + if (listInstances().some((i) => i.volumeName === name)) { + return reply.code(409).send({ error: '该数据卷正被某个实例使用,不能删除' }); + } + try { + await removeVolume(name); + return { ok: true }; + } catch (e: any) { + return reply.code(500).send({ error: e?.message || '删除数据卷失败' }); + } +}); + +// 查/改单实例的内存安全阀(soft / hard)。前端"实例卡片 → 安全"弹窗用。 +// GET 返回 per-instance 当前覆盖值 + 全局默认 + 实时内存(用于弹窗里展示)。 +// PUT 接受 {soft, hard},每项可为正整数 / null(null = 恢复默认)。 +app.get('/api/admin/instances/:id/mem-limits', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const id = (req.params as any).id; + const inst = findInstance(id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + let currentMB = 0; + try { + if ((await instanceRuntime(inst)) === 'running') currentMB = await instanceMemoryMB(inst); + } catch { + /* ignore:未运行时为 0 */ + } + return { + soft: inst.memSoftLimitMB ?? null, + hard: inst.memHardLimitMB ?? null, + defaultSoft: DEFAULT_SOFT_MB, + defaultHard: DEFAULT_HARD_MB, + currentMB, + watchdogEnabled: WATCHDOG_ENABLED, + intervalSec: WATCHDOG_INTERVAL_SEC, + }; +}); +app.put('/api/admin/instances/:id/mem-limits', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const id = (req.params as any).id; + const inst = findInstance(id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + const body = (req.body as any) ?? {}; + // 允许 number / null;其它类型都视为"未提供"(保持原值) + const norm = (v: any): number | null | undefined => + v === null ? null : typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : undefined; + const s = norm(body.soft); + const h = norm(body.hard); + // 取最终生效值(写入前校验) + const finalSoft = s === undefined ? inst.memSoftLimitMB ?? null : s; + const finalHard = h === undefined ? inst.memHardLimitMB ?? null : h; + try { + const pub = setInstanceMemLimits( + id, + finalSoft, + finalHard, + ); + return { instance: pub }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '阈值不合法' }); + } +}); + // 删除实例(仅管理员):默认保留数据卷,?purge=1 才永久删聊天记录 app.delete('/api/admin/instances/:id', async (req, reply) => { if (!requireAdmin(req, reply)) return; @@ -616,5 +713,92 @@ for (const pub of listInstances()) { } } +// Watchdog:KasmVNC/Xvnc 长跑会泄漏(实测 24h 可达 ~9 GiB),小内存机器会被拖垮。 +// 两档阈值,按"是否有人在用"决定时机: +// soft:mem >= soft 且当前无活跃会话 → 主动重启(柔和自愈,不打扰) +// hard:mem >= hard → 无视会话强制重启(防止 OOM) +// 优先级 hard > soft。两档阈值可在面板"管理 → 实例卡片 → 安全"按钮里单实例覆盖;缺省走 env。 +// +// env 默认(可被 per-instance 覆盖): +// WOC_INSTANCE_MEM_SOFT_MB soft 阈值;默认 1500 +// WOC_INSTANCE_MEM_HARD_MB hard 阈值;默认 2500(也兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB) +// WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒;默认 300(5 分钟),最小 60;0 关闭整个 watchdog +const DEFAULT_SOFT_MB = Math.max(0, Number(process.env.WOC_INSTANCE_MEM_SOFT_MB ?? 1500)); +const DEFAULT_HARD_MB = Math.max( + 0, + Number(process.env.WOC_INSTANCE_MEM_HARD_MB ?? process.env.WOC_INSTANCE_MEM_LIMIT_MB ?? 2500), +); +const WATCHDOG_INTERVAL_SEC = Math.max(60, Number(process.env.WOC_WATCHDOG_INTERVAL_SEC ?? 300)); +const WATCHDOG_ENABLED = WATCHDOG_INTERVAL_SEC > 0 && (DEFAULT_SOFT_MB > 0 || DEFAULT_HARD_MB > 0); + +// 单实例生效阈值:per-instance 覆盖优先;为 undefined 则用 env 默认。 +function effectiveLimits(inst: Instance): { soft: number; hard: number } { + return { + soft: inst.memSoftLimitMB ?? DEFAULT_SOFT_MB, + hard: inst.memHardLimitMB ?? DEFAULT_HARD_MB, + }; +} + +// "当前有人在远程会话" 启发式判定:复用控制权心跳。前端在用户鼠标/键盘/滚轮交互时 2.5s 节流 beat, +// 故 holder 在 TTL 内即视为"有人在主动操作"。只看屏(不交互)超过 TTL 后会被判为空闲——这是有意的, +// 软自愈宁愿在"看似空闲"时短暂打扰,也不要拖到 hard 强制重启。 +function hasActiveSession(id: string): boolean { + const h = controlHolders.get(id); + return !!h && Date.now() - h.at <= CONTROL_TTL; +} + +if (WATCHDOG_ENABLED) { + const recovering = new Set(); // 防重入:自愈期间跳过本实例 + const tick = async () => { + for (const pub of listInstances()) { + const inst = findInstance(pub.id); + if (!inst || recovering.has(inst.id)) continue; + try { + if ((await instanceRuntime(inst)) !== 'running') continue; + const mb = await instanceMemoryMB(inst); + if (mb === 0) continue; + const { soft, hard } = effectiveLimits(inst); + const active = hasActiveSession(inst.id); + let reason: 'hard' | 'soft' | null = null; + if (hard > 0 && mb >= hard) reason = 'hard'; + else if (soft > 0 && mb >= soft && !active) reason = 'soft'; + if (!reason) { + if (soft > 0 && mb >= soft && active) { + app.log.info( + `[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用(holder=${controlHolders.get(inst.id)?.username}),延后`, + ); + } + continue; + } + recovering.add(inst.id); + if (reason === 'hard') { + app.log.warn( + `[watchdog] ${inst.containerName} mem=${mb}MiB ≥ hard=${hard}MiB,强制重启(active=${active})`, + ); + } else { + app.log.warn( + `[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 且无活跃会话,柔和重启`, + ); + } + try { + await stopInstance(inst); + await runInstance(inst); + app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason})`); + } catch (e: any) { + app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}): ${e?.message || e}`); + } finally { + recovering.delete(inst.id); + } + } catch (e: any) { + app.log.warn(`[watchdog] ${pub.id} 检查异常: ${e?.message || e}`); + } + } + }; + setInterval(() => void tick(), WATCHDOG_INTERVAL_SEC * 1000).unref(); + console.log( + `[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s`, + ); +} + await app.listen({ port: PORT, host: HOST }); console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)`); diff --git a/panel/server/src/store.ts b/panel/server/src/store.ts index 385acd8..1a660f7 100644 --- a/panel/server/src/store.ts +++ b/panel/server/src/store.ts @@ -34,6 +34,11 @@ export interface Instance { kasmPassword: string; createdAt: string; createdBy: string; // userId + // 自愈 watchdog 的"安全阀",per-instance 覆盖全局默认;缺省时使用 env / 内置默认。 + // soft:内存超此值时,仅在"当前没有用户在远程会话"才主动重启(柔和自愈); + // hard:内存超此值时,无论是否有人在会话都重启(防止 OOM 拖垮宿主)。 + memSoftLimitMB?: number; + memHardLimitMB?: number; } interface Data { @@ -190,7 +195,39 @@ function sanitizeInstanceIds(ids: string[]): string[] { } export function publicInstance(i: Instance) { - return { id: i.id, name: i.name, createdAt: i.createdAt, createdBy: i.createdBy }; + return { + id: i.id, + name: i.name, + createdAt: i.createdAt, + createdBy: i.createdBy, + memSoftLimitMB: i.memSoftLimitMB, + memHardLimitMB: i.memHardLimitMB, + }; +} + +// 设置/清除某实例的 mem 安全阀。传 null 表示恢复默认(从对象上删字段)。 +// 校验:正整数;soft < hard;上限 20480 MiB(20 GiB)。 +export function setInstanceMemLimits( + id: string, + softMB: number | null, + hardMB: number | null, +) { + const inst = findInstance(id); + if (!inst) throw new Error('实例不存在'); + const norm = (v: number | null): number | undefined => { + if (v == null) return undefined; + if (!Number.isFinite(v) || !Number.isInteger(v) || v < 1 || v > 20480) { + throw new Error('阈值需为 1-20480 之间的整数(MiB)'); + } + return v; + }; + const s = norm(softMB); + const h = norm(hardMB); + if (s != null && h != null && s >= h) throw new Error('soft 阈值需小于 hard 阈值'); + inst.memSoftLimitMB = s; + inst.memHardLimitMB = h; + persist(); + return publicInstance(inst); } export function listInstances() { @@ -213,13 +250,34 @@ export function userCanAccess(u: User, instanceId: string) { return u.allowedInstances.includes(instanceId) && !!findInstance(instanceId); } -export function createInstance(name: string, createdBy: string, allowedUserIds: string[] = []) { - const id = randomBytes(5).toString('hex'); // 10 hex chars +// 复用旧卷时:从 woc-data- 解析回 id,让新实例的 containerName / volumeName 都对齐旧卷的 +// id(避免出现"卷叫 woc-data-abc,但实例 id 是 def"这种命名错配)。若旧 id 与现存实例冲突或卷名 +// 非标准前缀,则退回新生成 id,仅卷名指向旧卷。 +function parseIdFromVolume(volumeName: string): string | null { + const m = /^woc-data-([0-9a-f]{10})$/.exec(volumeName); + return m ? m[1] : null; +} + +export function createInstance( + name: string, + createdBy: string, + allowedUserIds: string[] = [], + reuseVolumeName?: string, +) { + let id = randomBytes(5).toString('hex'); // 10 hex chars + let volumeName = `woc-data-${id}`; + if (reuseVolumeName) { + const reusedId = parseIdFromVolume(reuseVolumeName); + if (reusedId && !findInstance(reusedId)) { + id = reusedId; + } + volumeName = reuseVolumeName; // 始终指向旧卷(即便 id 是新生成的) + } const inst: Instance = { id, name: name.trim() || `微信-${id.slice(0, 4)}`, containerName: `woc-wx-${id}`, - volumeName: `woc-data-${id}`, + volumeName, kasmUser: 'woc', // 用 hex(仅 0-9a-f):容器内 init 脚本以 `openssl passwd -apr1 ${PASSWORD}` 未加引号方式生成 .htpasswd, // base64url 可能含前导 '-' 而被 openssl 当作命令行选项,导致密码哈希为空、所有鉴权失败。hex 不含任何 shell 特殊字符。 diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index 6ce7be2..96577cb 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -24,6 +24,17 @@ export interface PanelInstance { name: string; createdAt: string; createdBy: string; + memSoftLimitMB?: number; + memHardLimitMB?: number; +} +export interface MemLimits { + soft: number | null; + hard: number | null; + defaultSoft: number; + defaultHard: number; + currentMB: number; + watchdogEnabled: boolean; + intervalSec: number; } export interface InstanceWithStatus extends PanelInstance { runtime: RuntimeState; @@ -75,11 +86,22 @@ export const api = { // 微信实例 listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'), - createInstance: (name: string, allowedUserIds: string[] = []) => + createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string) => req<{ instance: PanelInstance }>('/api/admin/instances', { method: 'POST', - body: JSON.stringify({ name, allowedUserIds }), + body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined }), }), + getInstanceMemLimits: (id: string) => + req(`/api/admin/instances/${id}/mem-limits`), + setInstanceMemLimits: (id: string, soft: number | null | undefined, hard: number | null | undefined) => + req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/mem-limits`, { + method: 'PUT', + body: JSON.stringify({ soft, hard }), + }), + listOrphanVolumes: () => + req<{ volumes: { name: string; createdAt?: string; sizeBytes?: number }[] }>('/api/admin/orphan-volumes'), + deleteOrphanVolume: (name: string) => + req(`/api/admin/orphan-volumes/${encodeURIComponent(name)}`, { method: 'DELETE' }), renameInstance: (id: string, name: string) => req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }), deleteInstance: (id: string, purge = false) => diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index dd75ced..43a5521 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -27,7 +27,10 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () const [resetTarget, setResetTarget] = useState(null); // 重置密码弹窗 const [deleteInst, setDeleteInst] = useState(null); // 删除实例弹窗 const [renameInst, setRenameInst] = useState(null); // 重命名实例弹窗 + const [securityInst, setSecurityInst] = useState(null); // 安全(内存阈值)弹窗 const [acting, setActing] = useState>({}); // 实例 id → 进行中的动作文案(启动中/升级中…) + // 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。 + const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]); const setAct = (id: string, label: string | null) => setActing((a) => { const n = { ...a }; @@ -48,6 +51,30 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () } catch (e: any) { setErr(e.message); } + // 孤儿卷列表独立 catch:docker 卷接口失败不应阻塞用户/实例视图 + try { + const { volumes } = await api.listOrphanVolumes(); + setOrphanVols(volumes); + } catch { + /* ignore */ + } + }; + + const removeOrphanVol = async (name: string) => { + const ok = await confirm({ + title: `彻底删除数据卷「${name}」?`, + body: '该卷里保存的微信本地数据(聊天记录缓存等)将永久消失,无法恢复。', + danger: true, + confirmText: '彻底删除', + }); + if (!ok) return; + try { + await api.deleteOrphanVolume(name); + toast('已删除数据卷', 'ok'); + setOrphanVols((vs) => vs.filter((v) => v.name !== name)); + } catch (e: any) { + toast(e.message || '删除失败', 'error'); + } }; useEffect(() => { @@ -171,6 +198,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () onRename={() => setRenameInst(inst)} onAssign={() => setAssignInst(inst)} onDelete={() => setDeleteInst(inst)} + onSecurity={() => setSecurityInst(inst)} /> ))} @@ -222,6 +250,35 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () ))} )} + {orphanVols.length > 0 && ( + <> +
+ 未使用的数据卷 + 删除实例时未勾选「彻底清除」会保留下来;可在新建实例时复用以继承聊天记录。 +
+
+ {orphanVols.map((v) => ( +
+
+ {v.name} +
+
+ {v.createdAt ? `创建于 ${v.createdAt.slice(0, 10)}` : '创建时间未知'} + {typeof v.sizeBytes === 'number' ? ` · ${(v.sizeBytes / 1024 / 1024).toFixed(1)} MB` : ''} +
+
+ + +
+
+ ))} +
+ + )} )} @@ -319,6 +376,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () }} /> )} + {securityInst && ( + setSecurityInst(null)} + onDone={() => { + toast('已保存安全阈值', 'ok'); + load(); + }} + /> + )} ); } @@ -402,6 +469,160 @@ function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: () ); } +// 「安全」弹窗:编辑某实例的内存安全阀(soft / hard)。 +// soft:超过且无人在远程会话时主动重启(柔和自愈,不打扰) +// hard:超过即强制重启(无视会话,防止 OOM) +// 留空 = 使用面板全局默认(来自 env)。 +function InstanceSecurity({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) { + const [data, setData] = useState(null); + // 输入字段:空串 = "使用默认"(→ 提交时映射为 null) + const [softStr, setSoftStr] = useState(''); + const [hardStr, setHardStr] = useState(''); + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(false); + const [loaded, setLoaded] = useState(false); + + // 首次加载 + 每 5s 刷新 currentMB(运行实例的实时内存) + useEffect(() => { + let alive = true; + const fetchOnce = async (initial: boolean) => { + try { + const d = await api.getInstanceMemLimits(inst.id); + if (!alive) return; + setData(d); + if (initial) { + setSoftStr(d.soft == null ? '' : String(d.soft)); + setHardStr(d.hard == null ? '' : String(d.hard)); + setLoaded(true); + } + } catch (e: any) { + if (alive && initial) { + setErr(e?.message || '读取失败'); + setLoaded(true); + } + } + }; + fetchOnce(true); + const t = window.setInterval(() => fetchOnce(false), 5000); + return () => { + alive = false; + window.clearInterval(t); + }; + }, [inst.id]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setErr(''); + const parse = (s: string): number | null => { + const t = s.trim(); + if (t === '') return null; + const n = Number(t); + if (!Number.isInteger(n)) throw new Error('阈值需为整数(MiB)'); + return n; + }; + let s: number | null; + let h: number | null; + try { + s = parse(softStr); + h = parse(hardStr); + } catch (e: any) { + setErr(e.message); + return; + } + if (s != null && h != null && s >= h) { + setErr('soft 阈值需小于 hard 阈值'); + return; + } + setBusy(true); + try { + await api.setInstanceMemLimits(inst.id, s, h); + onDone(); + onClose(); + } catch (e: any) { + setErr(e.message || '保存失败'); + } finally { + setBusy(false); + } + }; + + const resetToDefault = () => { + setSoftStr(''); + setHardStr(''); + }; + + return ( +
+
e.stopPropagation()} onSubmit={submit} style={{ maxWidth: 460 }}> +

安全 · {inst.name}

+ {!loaded ? ( +
读取中…
+ ) : !data ? ( +
{err || '读取失败'}
+ ) : ( + <> +
+ 当 KasmVNC/Xvnc 长跑泄漏内存时,面板的 watchdog 会自动重启实例。两档阈值(单位 MiB): +
+ soft:超过且无人在远程会话时柔和重启(不打扰使用者)。 +
+ hard:超过即强制重启,无视会话,防止 OOM 拖垮宿主。 +
+ +
+
+ 当前内存 + {data.currentMB > 0 ? `${data.currentMB} MiB` : '—'} +
+
+ 面板默认 + soft {data.defaultSoft} · hard {data.defaultHard} +
+
+ 巡检间隔 + + {data.watchdogEnabled ? `每 ${data.intervalSec}s` : 'watchdog 已关闭'} + +
+
+ +
soft 阈值(留空 = 用默认 {data.defaultSoft})
+ setSoftStr(e.target.value.replace(/[^0-9]/g, ''))} + /> +
hard 阈值(留空 = 用默认 {data.defaultHard})
+ setHardStr(e.target.value.replace(/[^0-9]/g, ''))} + /> +
+ 提示:日常活跃内存约 1500 MiB;soft 建议略高于此(如 2000),hard 建议远低于宿主可用内存(如 3000~4000)。 +
+ {err &&
{err}
} + + )} +
+ + + +
+
+
+ ); +} + function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) { const [purge, setPurge] = useState(false); const [err, setErr] = useState(''); @@ -459,6 +680,7 @@ function InstanceAdminCard({ onRename, onAssign, onDelete, + onSecurity, }: { inst: InstanceWithStatus; userCount: number; @@ -472,6 +694,7 @@ function InstanceAdminCard({ onRename: () => void; onAssign: () => void; onDelete: () => void; + onSecurity: () => void; }) { const wx = inst.wechat; const busy = BUSY_PHASES.includes(wx.phase); @@ -557,6 +780,9 @@ function InstanceAdminCard({ + @@ -656,13 +882,29 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: const [sel, setSel] = useState>(new Set()); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); + // 未使用的旧数据卷(之前删除实例但未勾选「彻底清除」时保留下来的),允许在此复用以继承聊天记录。 + const [orphans, setOrphans] = useState<{ name: string; createdAt?: string }[]>([]); + const [reuse, setReuse] = useState(''); // '' = 不复用,新建空卷 + + useEffect(() => { + let alive = true; + api + .listOrphanVolumes() + .then(({ volumes }) => alive && setOrphans(volumes)) + .catch(() => { + /* 读取失败时不阻塞创建:列表为空即可,照常新建空卷 */ + }); + return () => { + alive = false; + }; + }, []); const submit = async (e: React.FormEvent) => { e.preventDefault(); setErr(''); setBusy(true); try { - await api.createInstance(name.trim(), [...sel]); + await api.createInstance(name.trim(), [...sel], reuse || undefined); onDone(); } catch (e: any) { setErr(e.message || '创建失败'); @@ -683,6 +925,23 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: onToggle={(id) => setSel((s) => toggleSet(s, id))} empty="暂无子账号" /> + {orphans.length > 0 && ( + <> +
数据卷(可选)
+ +
+ 复用旧卷需**用原微信号扫码登录**才能解密历史消息;用别的号登录将看不到旧记录。 +
+ + )} {err &&
{err}
}
创建后会拉起一个新的微信容器,进入后扫码登录。
diff --git a/panel/web/src/styles.css b/panel/web/src/styles.css index aecf770..4a1d66c 100644 --- a/panel/web/src/styles.css +++ b/panel/web/src/styles.css @@ -732,6 +732,22 @@ button { .clip-area:focus { box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.16), 0 0 0 2px rgba(7, 193, 96, 0.35); } +.security-status { + margin-top: 12px; + padding: 10px 14px; + border-radius: 14px; + background: var(--mf-trough, #edeef1); + box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.12); + display: flex; + flex-direction: column; + gap: 6px; +} +.security-row { + display: flex; + justify-content: space-between; + font-size: 13px; + line-height: 1.4; +} .files-hint { font-size: 12px; color: var(--muted);