Files
SycunandParallels 470eb5ead1 feat: add runtime storage cleanup with per-category retention (#310) (#313)
Runtime artifacts (agent workspaces, tool-output spill, C2 payloads, chat
uploads, workflow checkpoints, diagnostic logs) previously accumulated
without bound: most were only removed when a conversation or project was
deleted, and tmp/c2 plus workflow checkpoints were never removed at all.

Add a storage cleaner with named per-category tasks (Gitea-style), a
settings page tab, and a background sweep that is off by default so
upgrading never deletes existing data.

Safety properties, since mis-deleting live task data costs far more than
the disk saved:
- dry-run is the default; a real cleanup requires dry_run=false together
  with confirm=true at the API layer, not just a frontend dialog
- sessions active within active_grace_hours are always skipped, and a
  failed activity lookup skips conservatively (fail closed)
- directories whose conversation/project no longer exists are reclaimed
  as orphans after orphan_grace_days
- scanners never follow symlinks and every candidate path is confined to
  its category root; deletion renames to a .tmp-for-deletion marker first
  so a crash leaves recoverable residue instead of a half-deleted dir
- storage:* permissions are admin-only; without the grantSystemRolePermissions
  skip the default branch would have given operators an irreversible
  file-deletion right

Also fix two confirmed leaks: DeleteConversation left chat_uploads files
on disk (their rows already vanished via ON DELETE CASCADE), and workflow
checkpoints had no deletion path at all.

Co-authored-by: Parallels <parallels@kali-linux-2025-2.localdomain>
2026-09-25 14:46:23 +08:00

368 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 运行空间占用统计与垃圾清理(系统设置 -> 存储清理)
//
// 数据来源:
// GET /api/storage/meta 清理策略与类别元信息
// GET /api/storage/status 文件系统容量 + 各类别占用/可回收量
// POST /api/storage/cleanup 预览(dry_run)或执行清理
// PUT /api/config 保存策略(storage 段)
(function () {
'use strict';
var meta = null;
var status = null;
var busy = false;
function esc(v) {
var s = v == null ? '' : String(v);
if (typeof escapeHtml === 'function') return escapeHtml(s);
return s.replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function st(key, fallback) {
if (typeof t === 'function') {
var v = t(key);
if (v && v !== key) return v;
}
return fallback;
}
// 类别名称/说明来自后端(中文硬编码),优先走 i18n,缺失时回退服务端文案。
function catLabel(cat) {
if (!cat) return '';
return st('settingsStorage.cat.' + cat.key, cat.label || cat.key);
}
function catHint(cat) {
if (!cat) return '';
return st('settingsStorage.catHint.' + cat.key, cat.hint || '');
}
function fmtBytes(n) {
var v = Number(n);
if (!Number.isFinite(v) || v <= 0) return '0 B';
var units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
var i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
// 字节不给小数,其余保留一位;数值较大时省去小数避免噪声
var digits = i === 0 ? 0 : (v >= 100 ? 0 : 1);
return v.toFixed(digits) + ' ' + units[i];
}
function setText(id, text) {
var el = document.getElementById(id);
if (el) el.textContent = text == null ? '' : String(text);
}
function intOr(raw, fallback) {
var v = parseInt(raw, 10);
return Number.isFinite(v) && v >= 0 ? v : fallback;
}
async function readErr(r, fallback) {
try {
var body = await r.json();
return (body && body.error) || fallback;
} catch (_) {
return fallback;
}
}
async function loadMeta() {
var r = await apiFetch('/api/storage/meta');
if (!r.ok) throw new Error(await readErr(r, st('settingsStorage.loadMetaFailed', '获取清理策略失败')));
meta = await r.json();
}
async function loadStatus(refresh) {
var r = await apiFetch('/api/storage/status' + (refresh ? '?refresh=1' : ''));
if (!r.ok) throw new Error(await readErr(r, st('settingsStorage.loadStatusFailed', '获取存储占用失败')));
status = await r.json();
}
async function postCleanup(body) {
var r = await apiFetch('/api/storage/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!r.ok) throw new Error(await readErr(r, st('settingsStorage.cleanupFailed', '清理请求失败')));
return await r.json();
}
function render() {
renderOverview();
renderRows();
renderPolicy();
showScannedAt();
}
function renderOverview() {
var ov = document.getElementById('storage-overview');
if (!ov) return;
ov.hidden = false;
var fs = (status && status.filesystem) || {};
var totals = (status && status.totals) || {};
setText('storage-disk-used', fs.available ? fmtBytes(fs.used_bytes) : st('settingsStorage.unavailable', '不可用'));
var fill = document.getElementById('storage-usage-bar-fill');
if (fill) {
var pct = fs.available ? Math.max(0, Math.min(100, Number(fs.used_percent) || 0)) : 0;
fill.style.width = pct.toFixed(1) + '%';
fill.classList.toggle('storage-usage-bar-fill--warn', pct >= 85);
}
setText('storage-disk-detail', fs.available
? fmtBytes(fs.free_bytes) + ' ' + st('settingsStorage.free', '可用') + ' / ' + fmtBytes(fs.total_bytes)
: '');
setText('storage-total-bytes', fmtBytes(totals.bytes));
setText('storage-total-units', (totals.units || 0) + ' ' + st('settingsStorage.itemsUnit', '项'));
setText('storage-reclaimable-bytes', fmtBytes(totals.reclaimable_bytes));
setText('storage-reclaimable-units', (totals.reclaimable_units || 0) + ' ' + st('settingsStorage.itemsUnit', '项'));
if (fs.available && fs.inodes_total > 0) {
var usedInodes = Math.max(0, fs.inodes_total - fs.inodes_free);
var inodePct = (usedInodes / fs.inodes_total) * 100;
setText('storage-inodes', usedInodes.toLocaleString() + ' / ' + fs.inodes_total.toLocaleString()
+ ' (' + inodePct.toFixed(1) + '%)');
} else {
setText('storage-inodes', st('settingsStorage.unavailable', '不可用'));
}
}
function renderRows() {
var tbody = document.getElementById('storage-category-rows');
if (!tbody) return;
var categories = (meta && meta.categories) || [];
if (!categories.length) {
tbody.innerHTML = '<tr><td colspan="7" class="storage-empty">'
+ esc(st('settingsStorage.noCategories', '暂无可清理类别')) + '</td></tr>';
return;
}
var byKey = {};
((status && status.categories) || []).forEach(function (c) { byKey[c.key] = c; });
tbody.innerHTML = categories.map(function (m) {
var s = byKey[m.key] || {};
var missing = !!s.missing;
var reclaimParts = [];
if (!missing) {
reclaimParts.push((s.reclaimable_units || 0) + ' ' + st('settingsStorage.itemsUnit', '项'));
if (s.orphan_units) {
reclaimParts.push(st('settingsStorage.orphanSuffix', '含孤儿') + ' ' + s.orphan_units);
}
if (s.skipped_active) {
reclaimParts.push(st('settingsStorage.skippedActive', '跳过活跃') + ' ' + s.skipped_active);
}
}
var retentionTitle = st('settingsStorage.defaultRetention', '默认') + ' '
+ (m.default_retention != null ? m.default_retention : '-') + ' '
+ st('settingsStorage.daysUnit', '天');
return '<tr class="' + (m.enabled ? '' : 'storage-row--disabled') + '">'
+ '<td><span class="storage-cat-label" title="' + esc(catHint(m)) + '">' + esc(catLabel(m)) + '</span></td>'
+ '<td>' + (missing
? '<span class="storage-muted">' + esc(st('settingsStorage.unused', '未使用')) + '</span>'
: '<code class="storage-root" title="' + esc(s.root || '') + '">' + esc(shortRoot(s.root)) + '</code>') + '</td>'
+ '<td>' + (missing ? '—' : (s.units || 0)) + '</td>'
+ '<td>' + (missing ? '—' : fmtBytes(s.bytes)) + '</td>'
+ '<td>' + (missing ? '—' : '<strong>' + fmtBytes(s.reclaimable_bytes) + '</strong>'
+ '<span class="storage-sub">' + esc(reclaimParts.join(' · ')) + '</span>') + '</td>'
+ '<td><input type="number" class="storage-retention-input" min="0" step="1"'
+ ' data-key="' + esc(m.key) + '" value="' + esc(m.retention_days != null ? m.retention_days : 0) + '"'
+ ' title="' + esc(retentionTitle) + '" aria-label="' + esc(retentionTitle) + '" /></td>'
+ '<td><input type="checkbox" class="storage-enabled-input" data-key="' + esc(m.key) + '"'
+ (m.enabled ? ' checked' : '') + ' aria-label="' + esc(catLabel(m)) + '" /></td>'
+ '</tr>';
}).join('');
}
// 目录列只展示末两级,完整路径放在 title 里,避免长绝对路径把表格撑破。
function shortRoot(root) {
var s = String(root || '');
if (!s) return '';
var parts = s.split('/').filter(Boolean);
if (parts.length <= 2) return s;
return '…/' + parts.slice(-2).join('/');
}
function renderPolicy() {
if (!meta) return;
var auto = document.getElementById('storage-auto-clean');
if (auto) auto.checked = !!meta.auto_clean;
setNumber('storage-interval-minutes', meta.interval_minutes);
setNumber('storage-orphan-grace-days', meta.orphan_grace_days);
setNumber('storage-active-grace-hours', meta.active_grace_hours);
}
function setNumber(id, value) {
var el = document.getElementById(id);
if (el) el.value = value != null ? value : '';
}
function showScannedAt() {
var el = document.getElementById('storage-scanned-at');
if (!el) return;
if (!status || !status.scanned_at) {
el.textContent = '';
return;
}
var d = new Date(status.scanned_at);
el.textContent = st('settingsStorage.scannedAt', '扫描于') + ' '
+ (isNaN(d.getTime()) ? String(status.scanned_at) : d.toLocaleString());
}
function renderResult(rep, isPreview) {
var el = document.getElementById('storage-result');
if (!el) return;
var totals = (rep && rep.totals) || {};
var head = isPreview
? st('settingsStorage.previewTitle', '预览结果(未删除任何文件)')
: st('settingsStorage.cleanDone', '清理完成');
var summary = isPreview
? fmtBytes(totals.reclaimable_bytes) + ' / ' + (totals.reclaimable_units || 0) + ' ' + st('settingsStorage.itemsUnit', '项')
: fmtBytes(totals.freed_bytes) + ' / ' + (totals.removed_units || 0) + ' ' + st('settingsStorage.itemsUnit', '项');
var rows = ((rep && rep.categories) || [])
.filter(function (c) {
return isPreview ? (c.reclaimable_units || 0) > 0 : ((c.removed_units || 0) > 0 || (c.errors || []).length > 0);
})
.map(function (c) {
var main = isPreview
? fmtBytes(c.reclaimable_bytes) + ' / ' + (c.reclaimable_units || 0) + ' ' + st('settingsStorage.itemsUnit', '项')
: fmtBytes(c.freed_bytes) + ' / ' + (c.removed_units || 0) + ' ' + st('settingsStorage.itemsUnit', '项');
var errs = (c.errors || []).slice(0, 3).map(esc).join('<br>');
return '<li><span>' + esc(catLabel(c)) + '</span><span>' + esc(main)
+ (errs ? '<br><span class="storage-error-text">' + errs + '</span>' : '')
+ '</span></li>';
});
var html = '<div class="storage-result-head"><strong>' + esc(head) + '</strong><span>' + esc(summary) + '</span></div>';
html += rows.length
? '<ul class="storage-result-list">' + rows.join('') + '</ul>'
: '<p class="storage-muted">' + esc(st('settingsStorage.nothingToClean', '当前没有符合清理条件的内容。')) + '</p>';
if (totals.skipped_active) {
html += '<p class="storage-muted">' + esc(st('settingsStorage.skippedActiveNote', '已跳过最近仍在活动的会话:')
+ totals.skipped_active + ' ' + st('settingsStorage.itemsUnit', '项')) + '</p>';
}
el.className = 'storage-result';
el.innerHTML = html;
el.hidden = false;
}
function showError(message) {
var el = document.getElementById('storage-result');
if (!el) return;
el.className = 'storage-result storage-result--error';
el.innerHTML = '<div class="storage-result-head"><strong>'
+ esc(st('settingsStorage.failed', '操作失败')) + '</strong><span>' + esc(message) + '</span></div>';
el.hidden = false;
}
function setButtonsDisabled(disabled) {
var section = document.getElementById('settings-section-storage');
if (!section) return;
section.querySelectorAll('button').forEach(function (btn) { btn.disabled = disabled; });
}
async function withBusy(fn) {
if (busy) return;
busy = true;
setButtonsDisabled(true);
try {
await fn();
} catch (e) {
showError(e && e.message ? e.message : String(e));
} finally {
busy = false;
setButtonsDisabled(false);
}
}
function collectPolicy() {
var payload = {
auto_clean: !!(document.getElementById('storage-auto-clean') || {}).checked,
interval_minutes: intOr(document.getElementById('storage-interval-minutes') && document.getElementById('storage-interval-minutes').value, 5),
orphan_grace_days: intOr(document.getElementById('storage-orphan-grace-days') && document.getElementById('storage-orphan-grace-days').value, 0),
active_grace_hours: intOr(document.getElementById('storage-active-grace-hours') && document.getElementById('storage-active-grace-hours').value, 1),
categories: {}
};
// 先用服务端元信息铺底,保证未渲染/缺字段时不会把既有策略清成默认值。
((meta && meta.categories) || []).forEach(function (m) {
payload.categories[m.key] = { enabled: !!m.enabled, retention_days: intOr(m.retention_days, 0) };
});
document.querySelectorAll('#storage-category-rows .storage-retention-input').forEach(function (el) {
var key = el.getAttribute('data-key');
if (!key || !payload.categories[key]) return;
payload.categories[key].retention_days = intOr(el.value, 0);
});
document.querySelectorAll('#storage-category-rows .storage-enabled-input').forEach(function (el) {
var key = el.getAttribute('data-key');
if (!key || !payload.categories[key]) return;
payload.categories[key].enabled = !!el.checked;
});
return payload;
}
window.initStorageSection = async function () {
if (typeof apiFetch !== 'function') return;
if (!document.getElementById('storage-category-rows')) return;
await withBusy(async function () {
await loadMeta();
await loadStatus(false);
render();
});
};
window.refreshStorageStatus = async function (force) {
if (typeof apiFetch !== 'function') return;
await withBusy(async function () {
await loadStatus(force === true);
render();
});
};
window.previewStorageCleanup = async function () {
await withBusy(async function () {
var rep = await postCleanup({ dry_run: true });
renderResult(rep, true);
});
};
window.runStorageCleanup = async function () {
var totals = (status && status.totals) || {};
var message = st('settingsStorage.confirmClean', '将永久删除约 {size}({count} 项)运行空间文件,无法恢复。建议先执行「预览可清理项」。确认继续?')
.replace('{size}', fmtBytes(totals.reclaimable_bytes))
.replace('{count}', String(totals.reclaimable_units || 0));
if (!window.confirm(message)) return;
await withBusy(async function () {
// 服务端要求真实删除必须同时带 dry_run=false 与 confirm=true。
var rep = await postCleanup({ dry_run: false, confirm: true });
renderResult(rep, false);
await loadStatus(true);
render();
});
};
window.saveStorageSettings = async function () {
var payload = collectPolicy();
await withBusy(async function () {
// 只调 PUT /api/config:UpdateConfig 会合并 storage 段并写回 config.yaml,
// 清理器每次执行都实时读取配置,无需 /api/config/apply 触发重启类副作用。
var r = await apiFetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ storage: payload })
});
if (!r.ok) throw new Error(await readErr(r, st('settingsStorage.saveFailed', '保存失败')));
await loadMeta();
renderPolicy();
renderRows();
window.alert(st('settingsStorage.saved', '清理策略已保存'));
});
};
})();