@xcanwin/manyoyo 8.6.12 → 8.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/manyoyo.js +12 -12
- package/lib/container-network.js +1 -0
- package/lib/name-template.js +52 -0
- package/lib/runtime-resolver.js +13 -1
- package/lib/web/index.html +84 -84
- package/lib/web/server.js +177 -7
- package/lib/web/storage.js +444 -0
- package/package.json +3 -1
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
|
|
7
|
+
// 磁盘占用页:宿主机上的工作目录(容器删了也会留下)和已删除容器的对话历史。
|
|
8
|
+
// 全部异步,不阻塞事件循环;只读,删除只允许 work/ 下没人用的直接子目录。
|
|
9
|
+
|
|
10
|
+
const SIZE_TIMEOUT_MS = 120000;
|
|
11
|
+
const SIZE_CACHE_MS = 60000;
|
|
12
|
+
const LIST_DIR_LIMIT = 500;
|
|
13
|
+
// 快捷对话 / 向导创建的工作目录名(时间戳)。默认根(~/.manyoyo/work)里的任何子目录都算 MANYOYO 的;
|
|
14
|
+
// 用户配置的目录(快捷对话目录)可能混着别的东西,那里只认时间戳名字
|
|
15
|
+
const MANAGED_NAME = /^\d{8}-\d{6}$/;
|
|
16
|
+
const isManagedChild = (extraReals, parent, name) => !name.startsWith('.') && (!extraReals.has(parent) || MANAGED_NAME.test(name));
|
|
17
|
+
const historyCache = new Map();
|
|
18
|
+
const sizeCache = new Map();
|
|
19
|
+
|
|
20
|
+
async function safeRealpath(target) {
|
|
21
|
+
try {
|
|
22
|
+
return await fs.promises.realpath(target);
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return path.resolve(target);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isInside(parent, child) {
|
|
29
|
+
const rel = path.relative(parent, child);
|
|
30
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 运行时里的容器:名字 → 状态,和每个容器挂载的宿主机路径。运行时不可用时抛错(调用方据此拒绝删除)。 */
|
|
34
|
+
async function readContainers(dockerExecAsync) {
|
|
35
|
+
const psOutput = String(await dockerExecAsync(['ps', '-a', '--format', '{{.Names}}\t{{.State}}'], { timeout: 15000 }) || '');
|
|
36
|
+
const states = new Map();
|
|
37
|
+
for (const line of psOutput.split('\n')) {
|
|
38
|
+
const [name, state] = line.split('\t').map(part => (part || '').trim());
|
|
39
|
+
if (name) states.set(name, state === 'running' ? 'running' : 'stopped');
|
|
40
|
+
}
|
|
41
|
+
const mounts = new Map();
|
|
42
|
+
if (states.size) {
|
|
43
|
+
const inspectOutput = String(await dockerExecAsync(
|
|
44
|
+
['inspect', '-f', '{{.Name}}{{range .Mounts}}{{if eq .Type "bind"}}\t{{.Source}}{{end}}{{end}}', ...states.keys()],
|
|
45
|
+
{ timeout: 20000 }
|
|
46
|
+
) || '');
|
|
47
|
+
for (const line of inspectOutput.split('\n')) {
|
|
48
|
+
const [rawName, ...sources] = line.split('\t');
|
|
49
|
+
const name = String(rawName || '').replace(/^\//, '').trim();
|
|
50
|
+
if (name) mounts.set(name, sources.map(item => item.trim()).filter(Boolean));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { states, mounts };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 每个会话历史文件里记的 hostPath;按 ino/mtime/size 缓存,没变的文件不重新解析 */
|
|
57
|
+
async function readHistoryEntries(historyDir) {
|
|
58
|
+
let files;
|
|
59
|
+
try {
|
|
60
|
+
files = (await fs.promises.readdir(historyDir)).filter(file => file.endsWith('.json'));
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const entries = [];
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
const filePath = path.join(historyDir, file);
|
|
67
|
+
const name = path.basename(file, '.json');
|
|
68
|
+
let stat;
|
|
69
|
+
try {
|
|
70
|
+
stat = await fs.promises.stat(filePath);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const sig = `${stat.ino}:${stat.mtimeMs}:${stat.size}`;
|
|
75
|
+
let cached = historyCache.get(filePath);
|
|
76
|
+
if (!cached || cached.sig !== sig) {
|
|
77
|
+
let hostPath = '';
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
|
|
80
|
+
const applied = parsed && parsed.applied && typeof parsed.applied === 'object' ? parsed.applied : {};
|
|
81
|
+
const snapshot = parsed && parsed.runtimeSnapshot && typeof parsed.runtimeSnapshot === 'object' ? parsed.runtimeSnapshot : {};
|
|
82
|
+
hostPath = String(applied.hostPath || snapshot.hostPath || '');
|
|
83
|
+
} catch (e) { /* 损坏的历史当没有路径 */ }
|
|
84
|
+
cached = { sig, hostPath };
|
|
85
|
+
historyCache.set(filePath, cached);
|
|
86
|
+
}
|
|
87
|
+
entries.push({ name, hostPath: cached.hostPath, bytes: stat.size, mtimeMs: stat.mtimeMs });
|
|
88
|
+
}
|
|
89
|
+
return entries;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 某个会话名对应的事件日志 / 投影文件(name.jsonl、name~agent.jsonl) */
|
|
93
|
+
async function listEventFiles(historyDir, name) {
|
|
94
|
+
const found = [];
|
|
95
|
+
for (const sub of ['events', 'projections']) {
|
|
96
|
+
let files = [];
|
|
97
|
+
try {
|
|
98
|
+
files = await fs.promises.readdir(path.join(historyDir, sub));
|
|
99
|
+
} catch (e) { /* 目录可能不存在 */ }
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
const base = file.replace(/\.(jsonl|json)$/, '');
|
|
102
|
+
if (base === name || base.startsWith(`${name}~`)) found.push(path.join(historyDir, sub, file));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return found;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// events/ 与 projections/ 各读一次,再按会话名分组累加(不要每份历史都重新 readdir)
|
|
109
|
+
async function eventBytesByName(historyDir) {
|
|
110
|
+
const totals = new Map();
|
|
111
|
+
for (const sub of ['events', 'projections']) {
|
|
112
|
+
let files = [];
|
|
113
|
+
try {
|
|
114
|
+
files = await fs.promises.readdir(path.join(historyDir, sub));
|
|
115
|
+
} catch (e) { /* 目录可能不存在 */ }
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const base = file.replace(/\.(jsonl|json)$/, '');
|
|
118
|
+
const name = base.split('~')[0];
|
|
119
|
+
try {
|
|
120
|
+
totals.set(name, (totals.get(name) || 0) + (await fs.promises.stat(path.join(historyDir, sub, file))).size);
|
|
121
|
+
} catch (e) { /* 忽略 */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return totals;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// workRoots 是 MANYOYO 自己的默认根;extraWorkRoots 来自用户配置(快捷对话目录),
|
|
128
|
+
// 先过一遍:必须是绝对路径,且不能是 / 或 $HOME 及其上级(配得太宽会把用户自己的时间戳目录当成自己的)
|
|
129
|
+
async function resolveRoots({ workRoots, extraWorkRoots = [], homeDir = '' }) {
|
|
130
|
+
const workReals = new Set();
|
|
131
|
+
const extraReals = new Set();
|
|
132
|
+
for (const root of workRoots) workReals.add(await safeRealpath(root));
|
|
133
|
+
const homeReal = homeDir ? await safeRealpath(homeDir) : '';
|
|
134
|
+
for (const root of extraWorkRoots) {
|
|
135
|
+
if (!path.isAbsolute(root)) continue;
|
|
136
|
+
const real = await safeRealpath(root);
|
|
137
|
+
if (real === path.parse(real).root || (homeReal && isInside(real, homeReal))) continue;
|
|
138
|
+
if (!workReals.has(real)) extraReals.add(real);
|
|
139
|
+
}
|
|
140
|
+
for (const real of extraReals) workReals.add(real);
|
|
141
|
+
return { workReals, extraReals };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 目录列表:以“宿主机目录”为一行(同名容器换路径、同路径多个容器都不会乱)。
|
|
146
|
+
* 来源:work/ 下的子目录 ∪ 历史里记的 hostPath;容器挂载只用来判断“有人在用”。
|
|
147
|
+
*/
|
|
148
|
+
async function buildStorageListing({ workRoots, extraWorkRoots = [], homeDir = '', historyDir, dockerExecAsync, configHostPaths = [] }) {
|
|
149
|
+
const { states, mounts } = await readContainers(dockerExecAsync);
|
|
150
|
+
const history = await readHistoryEntries(historyDir);
|
|
151
|
+
const { workReals, extraReals } = await resolveRoots({ workRoots, extraWorkRoots, homeDir });
|
|
152
|
+
// 专用目录:里面(不算点开头的文件)全是时间戳目录,说明这个目录就是拿来放 MANYOYO 工作目录的,
|
|
153
|
+
// 里面没有历史记录的时间戳目录(历史被清理过、或旧版本建的)也算自己的;混着别的东西(如 ~/Downloads)的不算
|
|
154
|
+
const dedicatedExtras = new Set();
|
|
155
|
+
for (const real of extraReals) {
|
|
156
|
+
try {
|
|
157
|
+
const entries = (await fs.promises.readdir(real, { withFileTypes: true })).filter(entry => !entry.name.startsWith('.'));
|
|
158
|
+
if (entries.length > 0 && entries.every(entry => entry.isDirectory() && MANAGED_NAME.test(entry.name))) dedicatedExtras.add(real);
|
|
159
|
+
} catch (e) { /* 读不了就不算专用 */ }
|
|
160
|
+
}
|
|
161
|
+
const historyPaths = new Set();
|
|
162
|
+
|
|
163
|
+
const liveMountPaths = [];
|
|
164
|
+
for (const [name, sources] of mounts) {
|
|
165
|
+
for (const source of sources) liveMountPaths.push({ name, source: await safeRealpath(source) });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 配置里写死的 hostPath(含模板的由调用方滤掉):没有容器也算在用
|
|
169
|
+
const configPaths = [];
|
|
170
|
+
for (const item of configHostPaths) {
|
|
171
|
+
if (typeof item === 'string' && path.isAbsolute(item)) configPaths.push(await safeRealpath(item));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const rows = new Map();
|
|
175
|
+
const ensureRow = async (rawPath) => {
|
|
176
|
+
const real = await safeRealpath(rawPath);
|
|
177
|
+
let row = rows.get(real);
|
|
178
|
+
if (!row) {
|
|
179
|
+
row = { path: real, sessions: [], managed: false };
|
|
180
|
+
rows.set(real, row);
|
|
181
|
+
}
|
|
182
|
+
return row;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
for (const root of workReals) {
|
|
186
|
+
try {
|
|
187
|
+
for (const entry of await fs.promises.readdir(root, { withFileTypes: true })) {
|
|
188
|
+
if (entry.isDirectory() && isManagedChild(extraReals, root, entry.name)) await ensureRow(path.join(root, entry.name));
|
|
189
|
+
}
|
|
190
|
+
} catch (e) { /* work 目录还不存在 */ }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const item of history) {
|
|
194
|
+
if (!item.hostPath || !path.isAbsolute(item.hostPath)) continue;
|
|
195
|
+
try {
|
|
196
|
+
if (!(await fs.promises.stat(item.hostPath)).isDirectory()) continue;
|
|
197
|
+
} catch (e) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const row = await ensureRow(item.hostPath);
|
|
201
|
+
historyPaths.add(row.path);
|
|
202
|
+
// 只要容器还在,历史里的路径就算它在用(取并集):挂载写法对不上或是上一代同名容器留下的,
|
|
203
|
+
// 最坏只是删不掉,不会把在用的目录判成可删
|
|
204
|
+
row.sessions.push({ name: item.name, state: states.get(item.name) || 'deleted' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const result = [];
|
|
208
|
+
for (const row of rows.values()) {
|
|
209
|
+
const parent = path.dirname(row.path);
|
|
210
|
+
// 用户配置的目录下,只有历史里记过的才算 MANYOYO 的;默认根下按名字认
|
|
211
|
+
row.managed = workReals.has(parent) && isManagedChild(extraReals, parent, path.basename(row.path)) &&
|
|
212
|
+
(!extraReals.has(parent) || dedicatedExtras.has(parent) || historyPaths.has(row.path));
|
|
213
|
+
// 容器没有历史(命令行建的)或历史里没记路径时,用挂载关系补上。
|
|
214
|
+
// 只认“挂的就是这个目录或它里面的子目录”:挂了上级目录(比如整个 /root)的容器删掉这个目录也不会坏,不算在用
|
|
215
|
+
const holders = new Set(liveMountPaths
|
|
216
|
+
.filter(item => isInside(row.path, item.source) || (row.managed && item.source === parent))
|
|
217
|
+
.map(item => item.name));
|
|
218
|
+
for (const name of holders) {
|
|
219
|
+
if (!row.sessions.some(item => item.name === name)) row.sessions.push({ name, state: states.get(name) || 'stopped' });
|
|
220
|
+
}
|
|
221
|
+
const configured = configPaths.some(item => isInside(row.path, item));
|
|
222
|
+
const inUse = holders.size > 0 || configured || row.sessions.some(item => item.state !== 'deleted');
|
|
223
|
+
let stat = null;
|
|
224
|
+
let empty = false;
|
|
225
|
+
try {
|
|
226
|
+
stat = await fs.promises.stat(row.path);
|
|
227
|
+
empty = (await fs.promises.readdir(row.path)).length === 0;
|
|
228
|
+
} catch (e) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
result.push({
|
|
232
|
+
path: row.path,
|
|
233
|
+
managed: row.managed,
|
|
234
|
+
status: inUse ? 'in-use' : (row.sessions.length ? 'history-only' : 'unused'),
|
|
235
|
+
sessions: row.sessions.sort((a, b) => a.name.localeCompare(b.name)),
|
|
236
|
+
mtimeMs: stat.mtimeMs,
|
|
237
|
+
empty,
|
|
238
|
+
deletable: row.managed && !inUse
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
result.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
242
|
+
|
|
243
|
+
const deletedHistories = [];
|
|
244
|
+
const eventBytes = await eventBytesByName(historyDir);
|
|
245
|
+
for (const item of history) {
|
|
246
|
+
if (states.has(item.name)) continue;
|
|
247
|
+
deletedHistories.push({ name: item.name, bytes: item.bytes + (eventBytes.get(item.name) || 0), updatedAt: new Date(item.mtimeMs).toISOString() });
|
|
248
|
+
}
|
|
249
|
+
deletedHistories.sort((a, b) => b.bytes - a.bytes);
|
|
250
|
+
return { rows: result, deletedHistories, workRoots: Array.from(workReals) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// du 很慢:同一目录的并发请求合并成一个,全局最多同时跑 DU_MAX_PARALLEL 个,多开几个标签页也不会把磁盘打满
|
|
254
|
+
const DU_MAX_PARALLEL = 2;
|
|
255
|
+
const duInFlight = new Map();
|
|
256
|
+
const duWaiting = [];
|
|
257
|
+
let duRunning = 0;
|
|
258
|
+
|
|
259
|
+
function acquireDu() {
|
|
260
|
+
if (duRunning < DU_MAX_PARALLEL) {
|
|
261
|
+
duRunning += 1;
|
|
262
|
+
return Promise.resolve();
|
|
263
|
+
}
|
|
264
|
+
return new Promise(resolve => duWaiting.push(resolve));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function releaseDu() {
|
|
268
|
+
const next = duWaiting.shift();
|
|
269
|
+
if (next) next();
|
|
270
|
+
else duRunning -= 1;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** `du -sk`:真实占用(含目录本身);超时就杀掉并返回 timedOut */
|
|
274
|
+
function directorySize(dir, options = {}) {
|
|
275
|
+
const hit = sizeCache.get(dir);
|
|
276
|
+
if (hit && Date.now() - hit.at < SIZE_CACHE_MS) return Promise.resolve(hit.value);
|
|
277
|
+
if (duInFlight.has(dir)) return duInFlight.get(dir);
|
|
278
|
+
const promise = acquireDu().then(() => runDu(dir, options)).finally(() => {
|
|
279
|
+
releaseDu();
|
|
280
|
+
duInFlight.delete(dir);
|
|
281
|
+
});
|
|
282
|
+
duInFlight.set(dir, promise);
|
|
283
|
+
return promise;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function runDu(dir, { timeoutMs = SIZE_TIMEOUT_MS } = {}) {
|
|
287
|
+
return new Promise(resolve => {
|
|
288
|
+
let output = '';
|
|
289
|
+
let settled = false;
|
|
290
|
+
const finish = value => {
|
|
291
|
+
if (settled) return;
|
|
292
|
+
settled = true;
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
if (!value.timedOut && !value.error) sizeCache.set(dir, { at: Date.now(), value });
|
|
295
|
+
resolve(value);
|
|
296
|
+
};
|
|
297
|
+
const child = spawn('du', ['-sk', '-x', '--', dir], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
298
|
+
const timer = setTimeout(() => {
|
|
299
|
+
child.kill('SIGKILL');
|
|
300
|
+
finish({ timedOut: true });
|
|
301
|
+
}, timeoutMs);
|
|
302
|
+
child.stdout.on('data', chunk => { output += chunk; });
|
|
303
|
+
child.on('error', () => finish({ error: true }));
|
|
304
|
+
child.on('close', () => {
|
|
305
|
+
const kb = Number(String(output).trim().split(/\s+/)[0]);
|
|
306
|
+
finish(Number.isFinite(kb) ? { bytes: kb * 1024 } : { error: true });
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 只读列出已知目录里的内容:sub 是相对路径,realpath 之后必须仍在目录内(挡 .. 和符号链接逃逸) */
|
|
312
|
+
async function listDirectory(root, sub) {
|
|
313
|
+
let rootReal;
|
|
314
|
+
let target;
|
|
315
|
+
try {
|
|
316
|
+
rootReal = await fs.promises.realpath(root);
|
|
317
|
+
// 路径不存在就直接 404,不退回字面路径(否则检查之后被换成符号链接就能逃出去)
|
|
318
|
+
target = await fs.promises.realpath(path.join(rootReal, String(sub || '')));
|
|
319
|
+
} catch (e) {
|
|
320
|
+
return { ok: false, status: 404, error: '目录不存在或无法读取' };
|
|
321
|
+
}
|
|
322
|
+
if (!isInside(rootReal, target)) return { ok: false, status: 400, error: '路径超出范围' };
|
|
323
|
+
let names;
|
|
324
|
+
try {
|
|
325
|
+
names = await fs.promises.readdir(target, { withFileTypes: true });
|
|
326
|
+
// 读完再确认一次:容器能写这些目录,检查和读取之间可能被换成指向别处的符号链接
|
|
327
|
+
if ((await fs.promises.realpath(target)) !== target) return { ok: false, status: 400, error: '路径超出范围' };
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return { ok: false, status: 404, error: '目录不存在或无法读取' };
|
|
330
|
+
}
|
|
331
|
+
const entries = [];
|
|
332
|
+
for (const entry of names.slice(0, LIST_DIR_LIMIT)) {
|
|
333
|
+
let stat = null;
|
|
334
|
+
try {
|
|
335
|
+
stat = await fs.promises.lstat(path.join(target, entry.name));
|
|
336
|
+
} catch (e) { /* 忽略 */ }
|
|
337
|
+
entries.push({
|
|
338
|
+
name: entry.name,
|
|
339
|
+
type: entry.isDirectory() ? 'dir' : (entry.isSymbolicLink() ? 'link' : 'file'),
|
|
340
|
+
size: stat && !entry.isDirectory() ? stat.size : null,
|
|
341
|
+
mtimeMs: stat ? stat.mtimeMs : null
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
entries.sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name));
|
|
345
|
+
return { ok: true, path: target, relative: path.relative(rootReal, target), entries, truncated: names.length > LIST_DIR_LIMIT };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const WALK_LIMIT = 200000;
|
|
349
|
+
|
|
350
|
+
/** 目录树里所有条目都和根在同一个磁盘上(不跟符号链接);读不了或条目太多按“不是”处理 */
|
|
351
|
+
async function isSingleDevice(root, lstat) {
|
|
352
|
+
try {
|
|
353
|
+
const dev = (await lstat(root)).dev;
|
|
354
|
+
const queue = [root];
|
|
355
|
+
let count = 0;
|
|
356
|
+
while (queue.length) {
|
|
357
|
+
const dir = queue.pop();
|
|
358
|
+
const handle = await fs.promises.opendir(dir);
|
|
359
|
+
for await (const entry of handle) {
|
|
360
|
+
if ((count += 1) > WALK_LIMIT) return false;
|
|
361
|
+
const full = path.join(dir, entry.name);
|
|
362
|
+
const stat = await lstat(full);
|
|
363
|
+
if (stat.dev !== dev) return false;
|
|
364
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) queue.push(full);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return true;
|
|
368
|
+
} catch (e) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 删除 work/ 下没人用的子目录;每次删之前重新算一遍列表,不信前端传来的状态。
|
|
375
|
+
* emptyOnly:只删空目录(rmdir,非空会失败,不会递归删除),批量清理空目录用
|
|
376
|
+
*/
|
|
377
|
+
async function removeManagedDirectory(listing, targetPath, { emptyOnly = false, lstat = fs.promises.lstat } = {}) {
|
|
378
|
+
const row = listing.rows.find(item => item.path === targetPath);
|
|
379
|
+
if (!row) return { ok: false, status: 404, error: '没有这个目录,请刷新列表' };
|
|
380
|
+
if (!row.deletable) return { ok: false, status: 409, error: row.managed ? '这个目录还有容器在用' : '只能删除 MANYOYO 自己创建的工作目录' };
|
|
381
|
+
if (emptyOnly) {
|
|
382
|
+
try {
|
|
383
|
+
await fs.promises.rmdir(row.path);
|
|
384
|
+
} catch (e) {
|
|
385
|
+
return { ok: false, status: 409, error: e && e.code === 'ENOTEMPTY' ? '目录不是空的' : '删除失败' };
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
// rm -r 会穿过挂载点:先完整走一遍,发现别的磁盘或读不了的条目就整体拒绝,一个文件都不删
|
|
389
|
+
if (!(await isSingleDevice(row.path, lstat))) return { ok: false, status: 409, error: '目录里有挂载的其它磁盘或读不了的条目,请手动处理' };
|
|
390
|
+
await fs.promises.rm(row.path, { recursive: true, force: true });
|
|
391
|
+
}
|
|
392
|
+
sizeCache.delete(row.path);
|
|
393
|
+
return { ok: true };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* 删除容器后顺手清掉它的空工作目录:必须在工作目录根里、名字是时间戳、没有容器挂它、而且是空的。
|
|
398
|
+
* 只用 rmdir(非空会失败),所以不会删到任何文件。尽力而为,返回是否删除了
|
|
399
|
+
*/
|
|
400
|
+
async function removeEmptyWorkDir({ hostPath, workRoots, extraWorkRoots, homeDir, dockerExecAsync }) {
|
|
401
|
+
if (!hostPath || !path.isAbsolute(hostPath)) return false;
|
|
402
|
+
let real;
|
|
403
|
+
try {
|
|
404
|
+
real = await fs.promises.realpath(hostPath);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
const { workReals, extraReals } = await resolveRoots({ workRoots, extraWorkRoots, homeDir });
|
|
409
|
+
const parent = path.dirname(real);
|
|
410
|
+
if (!workReals.has(parent) || !isManagedChild(extraReals, parent, path.basename(real))) return false;
|
|
411
|
+
try {
|
|
412
|
+
const { mounts } = await readContainers(dockerExecAsync);
|
|
413
|
+
for (const sources of mounts.values()) {
|
|
414
|
+
for (const source of sources) {
|
|
415
|
+
const sourceReal = await safeRealpath(source);
|
|
416
|
+
if (isInside(real, sourceReal) || sourceReal === parent) return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
await fs.promises.rmdir(real);
|
|
420
|
+
return true;
|
|
421
|
+
} catch (e) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// removeHistoryFile 由调用方传入(server 里的 removeWebSessionHistory,会同时清掉进程内缓存)
|
|
427
|
+
async function removeDeletedHistory(historyDir, listing, name, removeHistoryFile) {
|
|
428
|
+
if (!listing.deletedHistories.some(item => item.name === name)) return { ok: false, status: 404, error: '没有这份历史,或它的容器还在' };
|
|
429
|
+
removeHistoryFile(name);
|
|
430
|
+
for (const file of await listEventFiles(historyDir, name)) {
|
|
431
|
+
await fs.promises.rm(file, { force: true });
|
|
432
|
+
}
|
|
433
|
+
return { ok: true };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
module.exports = {
|
|
437
|
+
buildStorageListing,
|
|
438
|
+
directorySize,
|
|
439
|
+
listDirectory,
|
|
440
|
+
removeManagedDirectory,
|
|
441
|
+
removeEmptyWorkDir,
|
|
442
|
+
removeDeletedHistory,
|
|
443
|
+
readContainers
|
|
444
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xcanwin/manyoyo",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.7.0",
|
|
4
4
|
"imageVersion": "2.3.0-common",
|
|
5
5
|
"playwrightCliVersion": "0.1.22",
|
|
6
6
|
"patchrightCoreVersion": "1.64.1",
|
|
@@ -92,6 +92,8 @@
|
|
|
92
92
|
"**/test/**/*.test.js"
|
|
93
93
|
],
|
|
94
94
|
"testEnvironment": "node",
|
|
95
|
+
"globalSetup": "<rootDir>/test/support/global-setup.js",
|
|
96
|
+
"globalTeardown": "<rootDir>/test/support/global-teardown.js",
|
|
95
97
|
"modulePathIgnorePatterns": [
|
|
96
98
|
"<rootDir>/temp/",
|
|
97
99
|
"<rootDir>/.claude/"
|