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>
This commit is contained in:
SycunandParallels authored and GitHub committed 2026-09-25 14:46:23 +08:00
1 parent e9b6e0d86e
commit 470eb5ead1
28 files changed
+3356 -1

No files matched your search

+200
View File
@@ -46455,3 +46455,203 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
.mcp-stats-timeline__legend-item--blocked::before { background: #d99224; }
.mcp-stats-rate.is-muted,
.mcp-stats-tool-item__rate.is-muted { color: var(--text-muted); }
/* ============================================
系统设置 -> 存储清理(运行空间垃圾回收)
============================================ */
.storage-desc {
margin: 0 0 16px;
color: var(--text-secondary);
font-size: .8125rem;
line-height: 1.6;
}
/* display:grid/flex 会盖过 UA 的 [hidden],需显式提权 */
.storage-overview[hidden],
.storage-result[hidden] { display: none; }
.storage-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.storage-overview-card {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.storage-overview-card--accent { border-color: var(--warning-color); }
.storage-overview-label {
color: var(--text-secondary);
font-size: .75rem;
font-weight: 600;
}
.storage-overview-card strong {
font-size: 1.25rem;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.storage-overview-hint {
color: var(--text-secondary);
font-size: .75rem;
}
.storage-usage-bar {
height: 6px;
border-radius: 3px;
background: var(--bg-tertiary);
overflow: hidden;
}
.storage-usage-bar > span {
display: block;
height: 100%;
width: 0;
background: var(--primary-color);
transition: width .2s ease;
}
.storage-usage-bar > span.storage-usage-bar-fill--warn { background: var(--error-color); }
.storage-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.storage-scanned-at {
margin-left: auto;
color: var(--text-secondary);
font-size: .75rem;
}
.storage-result {
border: 1px solid var(--border-color);
border-left: 3px solid var(--success-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 12px 14px;
margin-bottom: 16px;
font-size: .8125rem;
}
.storage-result--error { border-left-color: var(--error-color); }
.storage-result-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
justify-content: space-between;
}
.storage-result-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.storage-result-list li {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--text-secondary);
}
.storage-error-text { color: var(--error-color); }
.storage-table .storage-cat-label { font-weight: 600; color: var(--text-primary); }
.storage-table .storage-sub {
display: block;
color: var(--text-secondary);
font-size: .6875rem;
margin-top: 2px;
}
.storage-root {
font-size: .75rem;
color: var(--text-secondary);
background: var(--bg-tertiary);
border-radius: 4px;
padding: 1px 5px;
}
.storage-muted { color: var(--text-secondary); font-size: .75rem; }
.storage-empty { text-align: center; color: var(--text-secondary); padding: 20px 0; }
.storage-row--disabled { opacity: .55; }
.storage-retention-input {
width: 72px;
padding: 4px 6px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary, #fff);
color: var(--text-primary);
font-size: .8125rem;
}
.storage-policy {
margin-top: 20px;
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.storage-policy h4 {
margin: 0 0 12px;
font-size: .875rem;
color: var(--text-primary);
}
.storage-policy-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
}
.storage-policy-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: .75rem;
color: var(--text-secondary);
}
.storage-policy-field input[type="number"] {
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary, #fff);
color: var(--text-primary);
font-size: .8125rem;
}
.storage-policy-field--switch {
flex-direction: row;
align-items: center;
gap: 8px;
font-size: .8125rem;
color: var(--text-primary);
}
.storage-policy-hint {
margin: 12px 0 0;
color: var(--text-secondary);
font-size: .75rem;
line-height: 1.6;
}
+65
View File
@@ -1784,6 +1784,7 @@
"security": "Security",
"rbac": "Platform permissions",
"audit": "Audit logs",
"storage": "Storage cleanup",
"infocollect": "Asset management"
},
"infocollect": {
@@ -3062,6 +3063,70 @@
"close": "×",
"newTerminal": "+"
},
"settingsStorage": {
"title": "Storage cleanup",
"subtitle": "Inspect and reclaim disk space used by runtime artifacts (agent workspaces, tool-output spill, C2 artifacts, chat uploads). Deletion is irreversible - preview first.",
"diskUsed": "Disk used",
"runtimeTotal": "Runtime footprint",
"reclaimable": "Reclaimable",
"inodes": "inode usage",
"inodesHint": "Scans can exhaust inodes with huge numbers of tiny files",
"refresh": "Rescan",
"preview": "Preview reclaimable",
"cleanNow": "Clean now",
"colCategory": "Category",
"colRoot": "Directory",
"colUnits": "Items",
"colSize": "Size",
"colReclaimable": "Reclaimable",
"colRetention": "Retention days",
"colEnabled": "Enabled",
"loading": "Loading...",
"policyTitle": "Automatic cleanup policy",
"autoClean": "Enable scheduled background cleanup",
"intervalMinutes": "Interval (minutes)",
"orphanGraceDays": "Orphan grace (days)",
"activeGraceHours": "Active session protection (hours)",
"policyHint": "Retention of 0 disables age-based cleanup, but directories whose conversation is already gone are still reclaimed. Sessions with recent activity are never cleaned. Automatic cleanup is off by default, so upgrading never deletes existing data.",
"save": "Save policy",
"loadMetaFailed": "Failed to load cleanup policy",
"loadStatusFailed": "Failed to load storage usage",
"cleanupFailed": "Cleanup request failed",
"unavailable": "Unavailable",
"free": "free",
"itemsUnit": "items",
"daysUnit": "days",
"noCategories": "No cleanable categories",
"orphanSuffix": "incl. orphans",
"skippedActive": "skipped active",
"defaultRetention": "Default",
"unused": "Unused",
"scannedAt": "Scanned at",
"previewTitle": "Preview (nothing was deleted)",
"cleanDone": "Cleanup finished",
"nothingToClean": "Nothing currently matches the cleanup policy.",
"skippedActiveNote": "Skipped sessions with recent activity: ",
"failed": "Operation failed",
"confirmClean": "This permanently deletes about {size} ({count} items) of runtime files and cannot be undone. Run \"Preview reclaimable\" first. Continue?",
"saveFailed": "Save failed",
"saved": "Cleanup policy saved",
"cat.workspace": "Agent workspace",
"catHint.workspace": "Agent download & scan artifacts (tmp/workspace), split per project/conversation.",
"cat.reduction": "Tool output spill",
"catHint.reduction": "Truncated spill files of oversized tool output (tmp/reduction); one file per execution, purely derived.",
"cat.conversation_artifacts": "Conversation artifacts",
"catHint.conversation_artifacts": "Summarization transcripts and the oversized user-input ledger (data/conversation_artifacts).",
"cat.plantask": "Plan task boards",
"catHint.plantask": "Eino multi-agent plan boards (skills/.eino/plantask), purely derived.",
"cat.c2_artifacts": "C2 artifacts",
"catHint.c2_artifacts": "C2 screenshots, uploads, pushed files and generated payloads (tmp/c2). Cleaning payloads invalidates their download links.",
"cat.chat_uploads": "Chat uploads",
"catHint.chat_uploads": "Files uploaded in conversations (chat_uploads/<date>/<conversation>).",
"cat.workflow_checkpoints": "Workflow checkpoints",
"catHint.workflow_checkpoints": "Workflow run checkpoints (data/workflow-checkpoints), only used to resume interrupted runs.",
"cat.diagnostic_logs": "Diagnostic logs",
"catHint.diagnostic_logs": "Daily-rotated diagnostic logs (log/diagnostic-*.log)."
},
"settingsAudit": {
"title": "Audit logs",
"description": "Platform admin actions (login, config, deletes). Does not log chat content, per-command terminal/WebShell runs, or per-tool invocations.",
+65
View File
@@ -1772,6 +1772,7 @@
"security": "安全设置",
"rbac": "平台权限",
"audit": "日志审计",
"storage": "存储清理",
"infocollect": "资产管理"
},
"infocollect": {
@@ -3050,6 +3051,70 @@
"close": "×",
"newTerminal": "+"
},
"settingsStorage": {
"title": "存储清理",
"subtitle": "统计并清理运行期间产生的磁盘垃圾(Agent 工作区、工具输出缓存、C2 产物、对话上传件等)。删除不可逆,建议先预览再执行。",
"diskUsed": "磁盘已用",
"runtimeTotal": "运行空间占用",
"reclaimable": "可回收",
"inodes": "inode 使用",
"inodesHint": "扫描产生的海量小文件可能先耗尽 inode",
"refresh": "重新扫描",
"preview": "预览可清理项",
"cleanNow": "立即清理",
"colCategory": "类别",
"colRoot": "目录",
"colUnits": "项目数",
"colSize": "占用",
"colReclaimable": "可回收",
"colRetention": "保留天数",
"colEnabled": "启用",
"loading": "加载中…",
"policyTitle": "自动清理策略",
"autoClean": "开启后台定时清理",
"intervalMinutes": "清理间隔(分钟)",
"orphanGraceDays": "孤儿目录宽限(天)",
"activeGraceHours": "活跃会话保护(小时)",
"policyHint": "保留天数为 0 表示不按保留期清理,但会话已删除的孤儿目录仍会回收;最近有活动的会话在任何情况下都不会被清理。默认关闭自动清理,升级不会自动删除既有数据。",
"save": "保存策略",
"loadMetaFailed": "获取清理策略失败",
"loadStatusFailed": "获取存储占用失败",
"cleanupFailed": "清理请求失败",
"unavailable": "不可用",
"free": "可用",
"itemsUnit": "项",
"daysUnit": "天",
"noCategories": "暂无可清理类别",
"orphanSuffix": "含孤儿",
"skippedActive": "跳过活跃",
"defaultRetention": "默认",
"unused": "未使用",
"scannedAt": "扫描于",
"previewTitle": "预览结果(未删除任何文件)",
"cleanDone": "清理完成",
"nothingToClean": "当前没有符合清理条件的内容。",
"skippedActiveNote": "已跳过最近仍在活动的会话:",
"failed": "操作失败",
"confirmClean": "将永久删除约 {size}({count} 项)运行空间文件,无法恢复。建议先执行「预览可清理项」。确认继续?",
"saveFailed": "保存失败",
"saved": "清理策略已保存",
"cat.workspace": "Agent 工作区",
"catHint.workspace": "Agent 下载与扫描产物的工作目录(tmp/workspace),按项目/会话分目录。",
"cat.reduction": "工具输出缓存",
"catHint.reduction": "超长工具输出落盘的截断文件(tmp/reduction),每条执行一个文件,属纯派生数据。",
"cat.conversation_artifacts": "会话产物",
"catHint.conversation_artifacts": "摘要记录与超长用户输入台账(data/conversation_artifacts)。",
"cat.plantask": "计划任务看板",
"catHint.plantask": "Eino 多代理计划看板 JSON(skills/.eino/plantask),属纯派生数据。",
"cat.c2_artifacts": "C2 产物",
"catHint.c2_artifacts": "C2 回传截图、上传件、下发文件与已生成的 payload 二进制(tmp/c2)。清理后对应 payload 下载链接会失效。",
"cat.chat_uploads": "对话上传文件",
"catHint.chat_uploads": "用户在对话中上传的附件(chat_uploads/日期/会话)。",
"cat.workflow_checkpoints": "工作流检查点",
"catHint.workflow_checkpoints": "工作流运行断点文件(data/workflow-checkpoints),仅用于恢复中断的运行。",
"cat.diagnostic_logs": "诊断日志",
"catHint.diagnostic_logs": "按天轮转的诊断日志(log/diagnostic-*.log)。"
},
"settingsAudit": {
"title": "日志审计",
"description": "记录平台管理类操作(登录、配置、删除等),不记录对话正文、终端/WebShell 每次命令与工具调用明细。",
+3
View File
@@ -688,6 +688,9 @@ function switchSettingsSection(section) {
if (section === 'audit' && typeof initAuditLogsSection === 'function') {
setTimeout(initAuditLogsSection, 0);
}
if (section === 'storage' && typeof initStorageSection === 'function') {
setTimeout(initStorageSection, 0);
}
}
// 打开设置
+367
View File
@@ -0,0 +1,367 @@
// 运行空间占用统计与垃圾清理(系统设置 -> 存储清理)
//
// 数据来源:
// 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', '清理策略已保存'));
});
};
})();