This commit is contained in:
Gloridust
2026-06-04 17:33:00 +08:00
Unverified
parent 9da876502c
commit d99c3b8bf6
8 changed files with 647 additions and 10 deletions
+31
View File
@@ -43,3 +43,34 @@ WOC_HTTP_PORT=36080
# b) 显式:在下面列出设备(逗号分隔),并可删掉那条 /host-dev 挂载。
# 留空 = 不映射摄像头(音频/麦克风不受影响)。
WOC_VIDEO_DEVICES=
# ── 实例资源 / 稳定性 ───────────────────────────────────────
# GPU 硬件编码:baseimage 检测到 /dev/dri/renderD* 时会给 Xvnc 加 -hw3dGPU 加速编码)。
# 在 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
+7
View File
@@ -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}
+61 -1
View File
@@ -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<void> {
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<string>): 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<void> {
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<number> {
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<RuntimeState> {
try {
const info = await docker.getContainer(inst.containerName).inspect();
+186 -2
View File
@@ -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},每项可为正整数 / nullnull = 恢复默认)。
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()) {
}
}
// WatchdogKasmVNC/Xvnc 长跑会泄漏(实测 24h 可达 ~9 GiB),小内存机器会被拖垮。
// 两档阈值,按"是否有人在用"决定时机:
// softmem >= soft 且当前无活跃会话 → 主动重启(柔和自愈,不打扰)
// hardmem >= 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<string>(); // 防重入:自愈期间跳过本实例
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} (多实例反代已就绪)`);
+62 -4
View File
@@ -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 MiB20 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> 解析回 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 特殊字符。
+24 -2
View File
@@ -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<MemLimits>(`/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) =>
+260 -1
View File
@@ -27,7 +27,10 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
const [resetTarget, setResetTarget] = useState<PanelUser | null>(null); // 重置密码弹窗
const [deleteInst, setDeleteInst] = useState<InstanceWithStatus | null>(null); // 删除实例弹窗
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
const [securityInst, setSecurityInst] = useState<InstanceWithStatus | null>(null); // 安全(内存阈值)弹窗
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 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)}
/>
))}
</div>
@@ -222,6 +250,35 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
))}
</div>
)}
{orphanVols.length > 0 && (
<>
<div className="section-row" style={{ marginTop: 22 }}>
<span className="section-title">使</span>
<span className="muted small"></span>
</div>
<div className="inst-grid">
{orphanVols.map((v) => (
<div key={v.name} className="inst-card">
<div className="inst-head">
<span className="inst-name" style={{ fontFamily: 'monospace', fontSize: 13 }}>{v.name}</span>
</div>
<div className="inst-sub">
{v.createdAt ? `创建于 ${v.createdAt.slice(0, 10)}` : '创建时间未知'}
{typeof v.sizeBytes === 'number' ? ` · ${(v.sizeBytes / 1024 / 1024).toFixed(1)} MB` : ''}
</div>
<div className="inst-admin-links">
<button className="btn-text" onClick={() => setCreatingInst(true)} title="去「新建实例」对话框,在「数据卷」下拉里选择复用此卷">
</button>
<button className="btn-text danger" onClick={() => removeOrphanVol(v.name)}>
</button>
</div>
</div>
))}
</div>
</>
)}
</>
)}
@@ -319,6 +376,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
}}
/>
)}
{securityInst && (
<InstanceSecurity
inst={securityInst}
onClose={() => setSecurityInst(null)}
onDone={() => {
toast('已保存安全阈值', 'ok');
load();
}}
/>
)}
</div>
);
}
@@ -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<import('../api').MemLimits | null>(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 (
<div className="modal-mask" onClick={onClose}>
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit} style={{ maxWidth: 460 }}>
<h2> · {inst.name}</h2>
{!loaded ? (
<div className="muted small" style={{ padding: '14px 0' }}></div>
) : !data ? (
<div className="error">{err || '读取失败'}</div>
) : (
<>
<div className="muted small" style={{ lineHeight: 1.6 }}>
KasmVNC/Xvnc watchdog MiB
<br />
<b>soft</b><b></b>使
<br />
<b>hard</b><b></b> OOM 宿
</div>
<div className="security-status">
<div className="security-row">
<span></span>
<b>{data.currentMB > 0 ? `${data.currentMB} MiB` : '—'}</b>
</div>
<div className="security-row">
<span></span>
<span className="muted">soft {data.defaultSoft} · hard {data.defaultHard}</span>
</div>
<div className="security-row">
<span></span>
<span className="muted">
{data.watchdogEnabled ? `${data.intervalSec}s` : 'watchdog 已关闭'}
</span>
</div>
</div>
<div className="field-label" style={{ marginTop: 12 }}>soft = {data.defaultSoft}</div>
<input
className="input"
inputMode="numeric"
placeholder={`${data.defaultSoft}`}
value={softStr}
onChange={(e) => setSoftStr(e.target.value.replace(/[^0-9]/g, ''))}
/>
<div className="field-label" style={{ marginTop: 8 }}>hard = {data.defaultHard}</div>
<input
className="input"
inputMode="numeric"
placeholder={`${data.defaultHard}`}
value={hardStr}
onChange={(e) => setHardStr(e.target.value.replace(/[^0-9]/g, ''))}
/>
<div className="muted small" style={{ marginTop: 6 }}>
1500 MiBsoft 2000hard 宿 3000~4000
</div>
{err && <div className="error">{err}</div>}
</>
)}
<div className="modal-actions">
<button type="button" className="btn-text" onClick={resetToDefault} disabled={busy}>
</button>
<button type="button" className="btn" onClick={onClose} disabled={busy}>
</button>
<button className="btn btn-primary" disabled={busy || !loaded || !data}>
</button>
</div>
</form>
</div>
);
}
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({
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志(排错)">
</button>
<button className="btn-text" onClick={onSecurity} title="设置内存阈值:超过 soft 且无人会话时柔和重启;超过 hard 强制重启">
</button>
<button className="btn-text danger" onClick={onDelete}>
</button>
@@ -656,13 +882,29 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
const [sel, setSel] = useState<Set<string>>(new Set());
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
// 未使用的旧数据卷(之前删除实例但未勾选「彻底清除」时保留下来的),允许在此复用以继承聊天记录。
const [orphans, setOrphans] = useState<{ name: string; createdAt?: string }[]>([]);
const [reuse, setReuse] = useState<string>(''); // '' = 不复用,新建空卷
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 && (
<>
<div className="field-label" style={{ marginTop: 12 }}></div>
<select className="input" value={reuse} onChange={(e) => setReuse(e.target.value)}>
<option value=""></option>
{orphans.map((v) => (
<option key={v.name} value={v.name}>
· {v.name}
{v.createdAt ? `${v.createdAt.slice(0, 10)} 创建)` : ''}
</option>
))}
</select>
<div className="muted small" style={{ marginTop: 4 }}>
****
</div>
</>
)}
{err && <div className="error">{err}</div>}
<div className="muted small" style={{ marginTop: 4 }}></div>
<div className="modal-actions">
+16
View File
@@ -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);