feat: delet old docker

This commit is contained in:
Gloridust
2026-06-04 17:42:06 +08:00
Unverified
parent d99c3b8bf6
commit ba8fe96486
4 changed files with 146 additions and 7 deletions
+49 -5
View File
@@ -129,7 +129,18 @@ export async function runInstance(inst: Instance): Promise<void> {
ExposedPorts: { '3000/tcp': {} },
HostConfig: hostConfig,
});
try {
await container.start();
} catch (e) {
// 启动失败但容器已被创建出来(Created 状态),不清理的话会成为"幽灵容器"——
// 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。
try {
await container.remove({ force: true });
} catch {
/* 容器已被外部移走或正在被清理,忽略 */
}
throw e;
}
}
// 确保实例容器在运行:缺失则按需创建(不会重建已有卷),停止则启动。
@@ -179,20 +190,31 @@ export async function removeInstance(inst: Instance, purgeVolume: boolean): Prom
}
}
// 列出"未被任何实例引用的 woc-data-* 数据卷"。删除实例时默认保留卷(聊天记录),但 panel 里没有
// UI 能看到这些孤儿卷;本接口让管理员可显式:在新建实例时复用旧卷继承聊天记录,或彻底删除。
// 仅看名字前缀 + docker 卷视角,不读卷内文件(避免提权访问)。
// 列出"未被任何容器引用的 woc-data-* 数据卷"。判定改为 docker 真实视角(不再仅看 store),
// 否则 Created 状态的"幽灵容器"会让卷被误判为孤儿,删除时撞 409real-world issue
// 早期 runInstance 启动失败漏清残留容器,留下 4 个 Created 容器各占一个卷名)。
export async function listOrphanVolumes(referencedVolumes: Set<string>): Promise<
Array<{ name: string; createdAt?: string; sizeBytes?: number }>
> {
// 容器视角:扫所有容器(含已停止 / Created),收集它们挂载的 woc-data-* 卷名
const allContainers = await docker.listContainers({ all: true });
const containerRefs = new Set<string>();
for (const c of allContainers) {
for (const m of c.Mounts || []) {
if (typeof m.Name === 'string' && m.Name.startsWith('woc-data-')) containerRefs.add(m.Name);
}
}
// 与 store 视角并集:取两者都未引用的卷
const referenced = new Set<string>([...referencedVolumes, ...containerRefs]);
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))
.filter((v: any) => typeof v?.Name === 'string' && v.Name.startsWith('woc-data-') && !referenced.has(v.Name))
.map((v: any) => ({
name: v.Name,
createdAt: v.CreatedAt,
// UsageData 仅在 docker engine 启用 -v size=true 时返回,常见情况下没有;缺失就不展示,避免一次 inspect 风暴
// UsageData 仅在 docker engine 启用 -v size=true 时返回,常见情况下没有;缺失就不展示
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));
@@ -203,6 +225,28 @@ export async function removeVolume(name: string): Promise<void> {
await docker.getVolume(name).remove({ force: true } as any);
}
// 列出"残留的 woc-wx-* 容器":在 docker 里存在但 store 没登记的(多为 runInstance 失败时
// 留下的 Created 状态容器,或用户手动 docker run 出来的)。给管理员一键清理。
export async function listOrphanContainers(
knownContainerNames: Set<string>,
): Promise<Array<{ id: string; name: string; status: string; volumeName?: string }>> {
const all = await docker.listContainers({ all: true });
const out: Array<{ id: string; name: string; status: string; volumeName?: string }> = [];
for (const c of all) {
const name = (c.Names || []).map((n) => n.replace(/^\//, '')).find((n) => n.startsWith('woc-wx-'));
if (!name) continue;
if (knownContainerNames.has(name)) continue;
const vol = (c.Mounts || []).map((m) => m.Name).find((n) => typeof n === 'string' && n.startsWith('woc-data-'));
out.push({ id: c.Id, name, status: c.Status || c.State || '', volumeName: vol });
}
return out;
}
// 强制删除一个残留容器(按短/全 id 或容器名都行)。
export async function removeContainerById(idOrName: string): Promise<void> {
await docker.getContainer(idOrName).remove({ force: true });
}
// 取实例容器的"working set"内存(MB):等同 docker stats 显示值 = usage - inactive_file。
// 用于 watchdog 检测 KasmVNC/Xvnc 长跑泄漏(21 小时可涨到 ~9 GiB),无法读取时返回 0(视为"暂未知"
// 不触发自愈,避免容器刚启动 stats 不可用就被误杀)。一次性 stats、不订阅 stream。
+31
View File
@@ -50,6 +50,8 @@ import {
typeInInstance,
listOrphanVolumes,
removeVolume,
listOrphanContainers,
removeContainerById,
instanceMemoryMB,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
@@ -273,6 +275,35 @@ app.get('/api/admin/orphan-volumes', async (req, reply) => {
}
});
// 列出"残留的 woc-wx-* 容器"docker 里存在但 store 没登记。多为 runInstance 启动失败遗留
// 的 Created 容器,会占着 woc-data-<id> 卷名让删卷报 409。提供给管理员一键清理。
app.get('/api/admin/orphan-containers', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const known = new Set(listInstances().map((i) => i.containerName));
try {
const containers = await listOrphanContainers(known);
return { containers };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '读取容器失败' });
}
});
// 强制删除一个残留容器。仅当它不在 store 的已知容器集中(防误删正在用的实例)。
app.delete('/api/admin/orphan-containers/:idOrName', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const idOrName = (req.params as any).idOrName;
if (!idOrName || typeof idOrName !== 'string') return reply.code(400).send({ error: '参数不合法' });
if (listInstances().some((i) => i.containerName === idOrName)) {
return reply.code(409).send({ error: '该容器属于现存实例,不能在此删除' });
}
try {
await removeContainerById(idOrName);
return { ok: true };
} 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;
+4
View File
@@ -102,6 +102,10 @@ export const api = {
req<{ volumes: { name: string; createdAt?: string; sizeBytes?: number }[] }>('/api/admin/orphan-volumes'),
deleteOrphanVolume: (name: string) =>
req(`/api/admin/orphan-volumes/${encodeURIComponent(name)}`, { method: 'DELETE' }),
listOrphanContainers: () =>
req<{ containers: { id: string; name: string; status: string; volumeName?: string }[] }>('/api/admin/orphan-containers'),
deleteOrphanContainer: (idOrName: string) =>
req(`/api/admin/orphan-containers/${encodeURIComponent(idOrName)}`, { 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) =>
+61 -1
View File
@@ -31,6 +31,8 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
// 残留 woc-wx-* 容器(runInstance 启动失败遗留的 Created 容器等):占着卷名让删卷报 409。
const [orphanConts, setOrphanConts] = useState<{ id: string; name: string; status: string; volumeName?: string }[]>([]);
const setAct = (id: string, label: string | null) =>
setActing((a) => {
const n = { ...a };
@@ -51,13 +53,43 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
} catch (e: any) {
setErr(e.message);
}
// 孤儿卷列表独立 catchdocker 接口失败不应阻塞用户/实例视图
// 孤儿卷 / 残留容器独立 catch:docker 接口失败不应阻塞用户/实例视图
try {
const { volumes } = await api.listOrphanVolumes();
setOrphanVols(volumes);
} catch {
/* ignore */
}
try {
const { containers } = await api.listOrphanContainers();
setOrphanConts(containers);
} catch {
/* ignore */
}
};
const removeOrphanCont = async (c: { id: string; name: string }) => {
const ok = await confirm({
title: `删除残留容器「${c.name}」?`,
body: '此容器不属于任何登记实例(多为创建失败遗留)。删除不会动数据卷,删后才能继续清理同名旧数据卷。',
danger: true,
confirmText: '删除容器',
});
if (!ok) return;
try {
await api.deleteOrphanContainer(c.id);
toast('已删除残留容器,可继续清理数据卷', 'ok');
setOrphanConts((cs) => cs.filter((x) => x.id !== c.id));
// 容器走了之后,原本被它占着的卷可能从"被引用"翻成"孤儿",刷新一次
try {
const { volumes } = await api.listOrphanVolumes();
setOrphanVols(volumes);
} catch {
/* ignore */
}
} catch (e: any) {
toast(e.message || '删除失败', 'error');
}
};
const removeOrphanVol = async (name: string) => {
@@ -250,6 +282,34 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
))}
</div>
)}
{orphanConts.length > 0 && (
<>
<div className="section-row" style={{ marginTop: 22 }}>
<span className="section-title"></span>
<span className="muted small"></span>
</div>
<div className="inst-grid">
{orphanConts.map((c) => (
<div key={c.id} className="inst-card">
<div className="inst-head">
<span className="inst-name" style={{ fontFamily: 'monospace', fontSize: 13 }}>{c.name}</span>
<span className="tag tag-off">{c.status || 'unknown'}</span>
</div>
{c.volumeName && (
<div className="inst-sub" style={{ fontFamily: 'monospace', fontSize: 12 }}>
{c.volumeName}
</div>
)}
<div className="inst-admin-links">
<button className="btn-text danger" onClick={() => removeOrphanCont(c)}>
</button>
</div>
</div>
))}
</div>
</>
)}
{orphanVols.length > 0 && (
<>
<div className="section-row" style={{ marginTop: 22 }}>