feat(dashboard): add log and cache cleanup in settings (#6822)

* feat(dashboard): add log and cache cleanup in settings

* refactor: simplify storage cleaner log config handling

* fix: Repair abnormal indentation

* fix(storage): harden cleanup config handling

Use typed config value access to avoid treating invalid values as
enabled flags or log paths during storage cleanup.

Also stop exposing raw backend exceptions in the dashboard storage
status API and direct users to server logs for details.

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
This commit is contained in:
bread
2026-03-23 09:33:37 +08:00
committed by GitHub
parent 04b7618f08
commit f984bced06
11 changed files with 753 additions and 3 deletions

View File

@@ -0,0 +1,271 @@
from __future__ import annotations
import os
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
@dataclass(frozen=True)
class LogFileConfig:
path: Path
enabled: bool
class StorageCleaner:
TARGET_LOGS = "logs"
TARGET_CACHE = "cache"
VALID_TARGETS = {TARGET_LOGS, TARGET_CACHE, "all"}
def __init__(
self,
config: Mapping[str, object],
*,
data_dir: Path | None = None,
temp_dir: Path | None = None,
) -> None:
self._config = config
self._data_dir = data_dir or Path(get_astrbot_data_path())
self._temp_dir = temp_dir or Path(get_astrbot_temp_path())
def get_status(self) -> dict:
logs = self._build_status(self.TARGET_LOGS)
cache = self._build_status(self.TARGET_CACHE)
return {
self.TARGET_LOGS: logs,
self.TARGET_CACHE: cache,
"total_bytes": logs["size_bytes"] + cache["size_bytes"],
}
def cleanup(self, target: str = "all") -> dict:
normalized_target = (target or "all").strip().lower()
if normalized_target not in self.VALID_TARGETS:
raise ValueError(f"Unsupported cleanup target: {target}")
targets = (
[self.TARGET_LOGS, self.TARGET_CACHE]
if normalized_target == "all"
else [normalized_target]
)
results: dict[str, dict] = {}
aggregates = {
"removed_bytes": 0,
"processed_files": 0,
"deleted_files": 0,
"truncated_files": 0,
"failed_files": 0,
}
for target_name in targets:
result = self._cleanup_target(target_name)
results[target_name] = result
for key in aggregates:
aggregates[key] += result[key]
status = self.get_status()
return {
"target": normalized_target,
"results": results,
**aggregates,
"status": status,
}
def _build_status(self, target: str) -> dict:
if target == self.TARGET_LOGS:
files = self._collect_log_files()
primary_path = self._data_dir / "logs"
elif target == self.TARGET_CACHE:
files = self._collect_cache_files()
primary_path = self._temp_dir
else:
raise ValueError(f"Unsupported cleanup target: {target}")
size_bytes, file_count = self._summarize_files(files)
return {
"size_bytes": size_bytes,
"file_count": file_count,
"path": str(primary_path),
"exists": primary_path.exists(),
}
def _cleanup_target(self, target: str) -> dict:
if target == self.TARGET_LOGS:
files = self._collect_log_files()
active_log_files = self._active_log_files()
elif target == self.TARGET_CACHE:
files = self._collect_cache_files()
active_log_files = set()
else:
raise ValueError(f"Unsupported cleanup target: {target}")
removed_bytes = 0
deleted_files = 0
truncated_files = 0
failed_files = 0
for file_path in sorted(files):
if not file_path.exists():
continue
try:
size = file_path.stat().st_size
except OSError as exc:
logger.warning("Failed to stat %s before cleanup: %s", file_path, exc)
failed_files += 1
continue
try:
if file_path in active_log_files:
file_path.write_bytes(b"")
truncated_files += 1
else:
file_path.unlink()
deleted_files += 1
removed_bytes += size
except OSError as exc:
logger.warning("Failed to clean %s: %s", file_path, exc)
failed_files += 1
if target == self.TARGET_CACHE:
self._cleanup_empty_dirs(self._temp_dir)
self._temp_dir.mkdir(parents=True, exist_ok=True)
logger.info(
"Storage cleanup finished: target=%s removed_bytes=%s deleted_files=%s truncated_files=%s failed_files=%s",
target,
removed_bytes,
deleted_files,
truncated_files,
failed_files,
)
return {
"removed_bytes": removed_bytes,
"processed_files": deleted_files + truncated_files,
"deleted_files": deleted_files,
"truncated_files": truncated_files,
"failed_files": failed_files,
}
def _collect_log_files(self) -> set[Path]:
files = set(self._iter_files(self._data_dir / "logs"))
for log_path in self._configured_log_paths():
files.update(self._iter_log_family_files(log_path))
return files
def _collect_cache_files(self) -> set[Path]:
files = set(self._iter_files(self._temp_dir))
files.update(self._data_dir.glob("plugins_custom_*.json"))
for extra_file in (
self._data_dir / "plugins.json",
self._data_dir / "sandbox_skills_cache.json",
):
if extra_file.is_file():
files.add(extra_file)
return files
def _log_file_configs(self) -> list[LogFileConfig]:
return [
LogFileConfig(
path=self._resolve_log_path(
self._get_optional_str("log_file_path"),
default_relative_path="logs/astrbot.log",
),
enabled=self._get_bool("log_file_enable", False),
),
LogFileConfig(
path=self._resolve_log_path(
self._get_optional_str("trace_log_path"),
default_relative_path="logs/astrbot.trace.log",
),
enabled=self._get_bool("trace_log_enable", False),
),
]
def _get_optional_str(self, key: str) -> str | None:
value = self._config.get(key)
return value if isinstance(value, str) else None
def _get_bool(self, key: str, default: bool = False) -> bool:
value = self._config.get(key, default)
return value if isinstance(value, bool) else default
def _configured_log_paths(self) -> set[Path]:
return {config.path for config in self._log_file_configs()}
def _active_log_files(self) -> set[Path]:
return {config.path for config in self._log_file_configs() if config.enabled}
def _resolve_log_path(
self,
configured_path: str | None,
*,
default_relative_path: str,
) -> Path:
path_value = configured_path or default_relative_path
path = Path(path_value)
if path.is_absolute():
return path.resolve()
return (self._data_dir / path).resolve()
def _iter_log_family_files(self, log_path: Path) -> set[Path]:
files: set[Path] = set()
parent_dir = log_path.parent
if log_path.is_file():
files.add(log_path)
if not parent_dir.exists():
return files
suffix = log_path.suffix
stem = log_path.stem if suffix else log_path.name
pattern = f"{stem}.*{suffix}" if suffix else f"{stem}.*"
for candidate in parent_dir.glob(pattern):
if candidate.is_file() and candidate != log_path:
files.add(candidate)
return files
@staticmethod
def _iter_files(path: Path) -> Iterable[Path]:
if path.is_file():
yield path
return
if not path.exists():
return
for child in path.rglob("*"):
if child.is_file():
yield child
@staticmethod
def _summarize_files(files: Iterable[Path]) -> tuple[int, int]:
total_size = 0
file_count = 0
for file_path in files:
if not file_path.exists() or not file_path.is_file():
continue
try:
total_size += file_path.stat().st_size
file_count += 1
except OSError as exc:
logger.debug("Skip %s during storage scan: %s", file_path, exc)
return total_size, file_count
@staticmethod
def _cleanup_empty_dirs(root_dir: Path) -> None:
if not root_dir.exists():
return
for dirpath, dirnames, filenames in os.walk(root_dir, topdown=False):
path = Path(dirpath)
if path == root_dir:
continue
try:
path.rmdir()
except OSError:
continue

View File

@@ -1,3 +1,4 @@
import asyncio
import os
import re
import threading
@@ -17,6 +18,7 @@ from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.helper import check_migration_needed_v4
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import get_dashboard_version
from astrbot.core.utils.storage_cleaner import StorageCleaner
from astrbot.core.utils.version_comparator import VersionComparator
from .route import Response, Route, RouteContext
@@ -39,10 +41,13 @@ class StatRoute(Route):
"/stat/changelog": ("GET", self.get_changelog),
"/stat/changelog/list": ("GET", self.list_changelog_versions),
"/stat/first-notice": ("GET", self.get_first_notice),
"/stat/storage": ("GET", self.get_storage_status),
"/stat/storage/cleanup": ("POST", self.cleanup_storage),
}
self.db_helper = db_helper
self.register_routes()
self.core_lifecycle = core_lifecycle
self.storage_cleaner = StorageCleaner(self.config)
async def restart_core(self):
if DEMO_MODE:
@@ -89,6 +94,31 @@ class StatRoute(Route):
async def get_start_time(self):
return Response().ok({"start_time": self.core_lifecycle.start_time}).__dict__
async def get_storage_status(self):
try:
status = await asyncio.to_thread(self.storage_cleaner.get_status)
return Response().ok(status).__dict__
except Exception:
logger.error("获取存储占用失败", exc_info=True)
return (
Response().error("获取存储占用失败,请查看后端日志了解详情。").__dict__
)
async def cleanup_storage(self):
try:
data = await request.get_json(silent=True)
target = "all"
if isinstance(data, dict):
target = str(data.get("target", "all"))
result = await asyncio.to_thread(self.storage_cleaner.cleanup, target)
return Response().ok(result).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception:
logger.error("清理存储失败", exc_info=True)
return Response().error("清理存储失败,请查看后端日志了解详情。").__dict__
async def get_stat(self):
offset_sec = request.args.get("offset_sec", 86400)
offset_sec = int(offset_sec)

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 231 icons */
/* Auto-generated MDI subset 235 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -112,6 +112,10 @@
content: "\F09D1";
}
.mdi-broom::before {
content: "\F00E2";
}
.mdi-bug::before {
content: "\F00E4";
}
@@ -300,6 +304,10 @@
content: "\F1640";
}
.mdi-database-refresh-outline::before {
content: "\F1634";
}
.mdi-delete::before {
content: "\F01B4";
}
@@ -308,6 +316,10 @@
content: "\F09E7";
}
.mdi-delete-sweep-outline::before {
content: "\F0C62";
}
.mdi-dots-hexagon::before {
content: "\F15FF";
}
@@ -728,6 +740,10 @@
content: "\F0A66";
}
.mdi-qrcode::before {
content: "\F0432";
}
.mdi-refresh::before {
content: "\F0450";
}

View File

@@ -0,0 +1,241 @@
<template>
<div class="storage-cleanup-panel">
<div class="text-subtitle-1 font-weight-medium mb-1">
{{ tm('system.cleanup.title') }}
</div>
<div class="text-body-2 text-medium-emphasis mb-4">
{{ tm('system.cleanup.subtitle') }}
</div>
<v-expansion-panels variant="accordion">
<v-expansion-panel elevation="0" class="border rounded-lg">
<v-expansion-panel-title class="py-4">
<div class="d-flex align-center justify-space-between w-100 pr-4 ga-3">
<div class="d-flex align-center ga-3">
<v-icon color="warning">mdi-broom</v-icon>
<div>
<div class="font-weight-medium">{{ tm('system.cleanup.panel.title') }}</div>
<div class="text-caption text-medium-emphasis">
{{ tm('system.cleanup.panel.subtitle', { size: formatBytes(storageStatus.total_bytes || 0) }) }}
</div>
</div>
</div>
<v-chip size="small" color="warning" variant="tonal">
{{ formatBytes(storageStatus.total_bytes || 0) }}
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="d-flex flex-wrap ga-2 mb-4">
<v-btn
size="small"
variant="tonal"
color="primary"
:loading="statusLoading"
@click="loadStorageStatus"
>
<v-icon class="mr-2">mdi-refresh</v-icon>
{{ tm('system.cleanup.refresh') }}
</v-btn>
<v-btn
size="small"
color="warning"
:loading="cleaningTarget === 'all'"
@click="cleanupStorage('all')"
>
<v-icon class="mr-2">mdi-broom</v-icon>
{{ tm('system.cleanup.cleanAll') }}
</v-btn>
</div>
<v-row dense>
<v-col
v-for="item in storageCards"
:key="item.key"
cols="12"
md="6"
>
<v-card variant="tonal" class="h-100">
<v-card-text>
<div class="d-flex align-start justify-space-between ga-3">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ item.title }}
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ item.subtitle }}
</div>
</div>
<v-icon :color="item.color">{{ item.icon }}</v-icon>
</div>
<div class="text-h5 mt-4">
{{ formatBytes(item.sizeBytes) }}
</div>
<div class="text-caption text-medium-emphasis mt-1">
{{ tm('system.cleanup.fileCount', { count: item.fileCount }) }}
</div>
<div class="text-caption text-medium-emphasis mt-2 storage-cleanup-path">
{{ item.path }}
</div>
<v-btn
class="mt-4"
size="small"
:color="item.color"
:loading="cleaningTarget === item.key"
@click="cleanupStorage(item.key)"
>
<v-icon class="mr-2">mdi-delete-sweep-outline</v-icon>
{{ item.buttonText }}
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
import { useToastStore } from '@/stores/toast';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
const { tm } = useModuleI18n('features/settings');
const toastStore = useToastStore();
const confirmDialog = useConfirmDialog();
const statusLoading = ref(false);
const cleaningTarget = ref('');
const storageStatus = ref({
logs: {
size_bytes: 0,
file_count: 0,
path: '',
exists: false
},
cache: {
size_bytes: 0,
file_count: 0,
path: '',
exists: false
},
total_bytes: 0
});
const showToast = (message, color = 'success') => {
toastStore.add({
message,
color,
timeout: 3000
});
};
const formatBytes = (bytes) => {
const value = Number(bytes || 0);
if (value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const decimals = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${size.toFixed(decimals)} ${units[unitIndex]}`;
};
const storageCards = computed(() => [
{
key: 'cache',
title: tm('system.cleanup.targets.cache.title'),
subtitle: tm('system.cleanup.targets.cache.subtitle'),
buttonText: tm('system.cleanup.targets.cache.button'),
icon: 'mdi-database-refresh-outline',
color: 'primary',
sizeBytes: storageStatus.value.cache?.size_bytes || 0,
fileCount: storageStatus.value.cache?.file_count || 0,
path: storageStatus.value.cache?.path || '-'
},
{
key: 'logs',
title: tm('system.cleanup.targets.logs.title'),
subtitle: tm('system.cleanup.targets.logs.subtitle'),
buttonText: tm('system.cleanup.targets.logs.button'),
icon: 'mdi-file-document-outline',
color: 'warning',
sizeBytes: storageStatus.value.logs?.size_bytes || 0,
fileCount: storageStatus.value.logs?.file_count || 0,
path: storageStatus.value.logs?.path || '-'
}
]);
const loadStorageStatus = async () => {
statusLoading.value = true;
try {
const res = await axios.get('/api/stat/storage');
if (res.data.status !== 'ok') {
showToast(res.data.message || tm('system.cleanup.messages.statusFailed'), 'error');
return;
}
storageStatus.value = res.data.data || storageStatus.value;
} catch (error) {
showToast(error?.response?.data?.message || tm('system.cleanup.messages.statusFailed'), 'error');
} finally {
statusLoading.value = false;
}
};
const cleanupStorage = async (target) => {
const confirmed = await askForConfirmation(
tm('system.cleanup.confirm', { target: tm(`system.cleanup.targetNames.${target}`) }),
confirmDialog
);
if (!confirmed) return;
cleaningTarget.value = target;
try {
const res = await axios.post('/api/stat/storage/cleanup', { target });
if (res.data.status !== 'ok') {
showToast(res.data.message || tm('system.cleanup.messages.cleanupFailed'), 'error');
return;
}
const cleanupData = res.data.data || {};
storageStatus.value = cleanupData.status || storageStatus.value;
showToast(
tm('system.cleanup.messages.cleanupSuccess', {
size: formatBytes(cleanupData.removed_bytes || 0),
count: cleanupData.processed_files || 0
})
);
} catch (error) {
showToast(error?.response?.data?.message || tm('system.cleanup.messages.cleanupFailed'), 'error');
} finally {
cleaningTarget.value = '';
}
};
onMounted(() => {
loadStorageStatus();
});
</script>
<style scoped>
.storage-cleanup-path {
word-break: break-all;
}
.storage-cleanup-panel {
margin: 8px 0 12px;
}
</style>

View File

@@ -42,6 +42,40 @@
"title": "Backup & Restore",
"subtitle": "Export or import all AstrBot data for easy migration to a new server",
"button": "Backup Manager"
},
"cleanup": {
"title": "Log & Cache Cleanup",
"subtitle": "Review disk usage for logs and caches, then clean them from the UI without shell commands.",
"refresh": "Refresh Usage",
"cleanAll": "Clean All",
"panel": {
"title": "Cleanup Details",
"subtitle": "Current usage: {size}"
},
"fileCount": "{count} files",
"confirm": "Clean {target} now?",
"targetNames": {
"cache": "cache",
"logs": "logs",
"all": "logs and cache"
},
"targets": {
"cache": {
"title": "Cache",
"subtitle": "Remove temporary files, plugin market cache, and skill cache.",
"button": "Clean Cache"
},
"logs": {
"title": "Logs",
"subtitle": "Remove rotated logs and truncate the current active log files.",
"button": "Clean Logs"
}
},
"messages": {
"statusFailed": "Failed to load storage usage",
"cleanupSuccess": "Cleared {count} files and freed {size}",
"cleanupFailed": "Cleanup failed"
}
}
},
"sidebar": {

View File

@@ -42,6 +42,40 @@
"title": "Резервное копирование",
"subtitle": "Важнейший инструмент для безопасного переноса данных между серверами.",
"button": "Управление бэкапами"
},
"cleanup": {
"title": "Очистка логов и кэша",
"subtitle": "Показывает текущий размер логов и кэша и позволяет очистить их прямо из WebUI.",
"refresh": "Обновить",
"cleanAll": "Очистить все",
"panel": {
"title": "Детали очистки",
"subtitle": "Текущий размер: {size}"
},
"fileCount": "{count} файлов",
"confirm": "Очистить {target}?",
"targetNames": {
"cache": "кэш",
"logs": "логи",
"all": "логи и кэш"
},
"targets": {
"cache": {
"title": "Кэш",
"subtitle": "Удаляет временные файлы, кэш каталога плагинов и кэш навыков.",
"button": "Очистить кэш"
},
"logs": {
"title": "Логи",
"subtitle": "Удаляет старые файлы логов и очищает текущие активные логи.",
"button": "Очистить логи"
}
},
"messages": {
"statusFailed": "Не удалось получить размер хранилища",
"cleanupSuccess": "Очищено {count} файлов, освобождено {size}",
"cleanupFailed": "Ошибка очистки"
}
}
},
"sidebar": {
@@ -177,4 +211,4 @@
"copyFailed": "Ошибка копирования"
}
}
}
}

View File

@@ -42,6 +42,40 @@
"title": "数据备份与恢复",
"subtitle": "导出或导入 AstrBot 的所有数据,方便迁移到新服务器",
"button": "备份管理"
},
"cleanup": {
"title": "日志与缓存清理",
"subtitle": "查看当前日志和缓存占用,并一键清理。",
"refresh": "刷新占用",
"cleanAll": "全部清理",
"panel": {
"title": "清理详情",
"subtitle": "当前占用 {size}"
},
"fileCount": "{count} 个文件",
"confirm": "确定要清理 {target} 吗?",
"targetNames": {
"cache": "缓存",
"logs": "日志",
"all": "日志和缓存"
},
"targets": {
"cache": {
"title": "缓存",
"subtitle": "清理临时文件、插件市场缓存和技能缓存。",
"button": "清理缓存"
},
"logs": {
"title": "日志",
"subtitle": "清理历史日志,并清空当前正在写入的日志文件内容。",
"button": "清理日志"
}
},
"messages": {
"statusFailed": "加载存储占用失败",
"cleanupSuccess": "已清理 {count} 个文件,释放 {size}",
"cleanupFailed": "清理失败"
}
}
},
"sidebar": {

View File

@@ -1,6 +1,6 @@
<template>
<div style="background-color: var(--v-theme-surface, #fff); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px;">
<div style="background-color: var(--v-theme-surface, #fff); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 24px;">
<v-list lines="two">
<v-list-subheader>{{ tm('network.title') }}</v-list-subheader>
@@ -63,6 +63,10 @@
<v-btn style="margin-top: 16px;" color="error" @click="restartAstrBot">{{ tm('system.restart.button') }}</v-btn>
</v-list-item>
<v-list-item class="py-2">
<StorageCleanupPanel />
</v-list-item>
<v-list-subheader>{{ tm('apiKey.title') }}</v-list-subheader>
<v-list-item :subtitle="tm('apiKey.subtitle')">
@@ -230,6 +234,7 @@ import ProxySelector from '@/components/shared/ProxySelector.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import BackupDialog from '@/components/shared/BackupDialog.vue';
import StorageCleanupPanel from '@/components/shared/StorageCleanupPanel.vue';
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';

View File

@@ -0,0 +1,85 @@
from pathlib import Path
from astrbot.core.utils.storage_cleaner import StorageCleaner
def _write_bytes(path: Path, size: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x" * size)
def test_storage_cleaner_status_includes_logs_and_cache(tmp_path):
data_dir = tmp_path / "data"
temp_dir = data_dir / "temp"
logs_dir = data_dir / "logs"
_write_bytes(temp_dir / "audio" / "temp.wav", 128)
_write_bytes(data_dir / "plugins.json", 64)
_write_bytes(data_dir / "sandbox_skills_cache.json", 32)
_write_bytes(logs_dir / "astrbot.log", 256)
_write_bytes(logs_dir / "astrbot.2026-03-22.log", 128)
cleaner = StorageCleaner(
{
"log_file_enable": True,
"log_file_path": "logs/astrbot.log",
"trace_log_enable": False,
},
data_dir=data_dir,
temp_dir=temp_dir,
)
status = cleaner.get_status()
assert status["logs"]["size_bytes"] == 384
assert status["logs"]["file_count"] == 2
assert status["cache"]["size_bytes"] == 224
assert status["cache"]["file_count"] == 3
assert status["total_bytes"] == 608
def test_storage_cleaner_cleanup_truncates_active_log_and_removes_cache(tmp_path):
data_dir = tmp_path / "data"
temp_dir = data_dir / "temp"
logs_dir = data_dir / "logs"
active_log = logs_dir / "astrbot.log"
rotated_log = logs_dir / "astrbot.2026-03-22.log"
trace_log = logs_dir / "astrbot.trace.log"
temp_file = temp_dir / "nested" / "voice.wav"
registry_cache = data_dir / "plugins_custom_abc.json"
_write_bytes(active_log, 300)
_write_bytes(rotated_log, 150)
_write_bytes(trace_log, 90)
_write_bytes(temp_file, 120)
_write_bytes(registry_cache, 80)
cleaner = StorageCleaner(
{
"log_file_enable": True,
"log_file_path": "logs/astrbot.log",
"trace_log_enable": True,
"trace_log_path": "logs/astrbot.trace.log",
},
data_dir=data_dir,
temp_dir=temp_dir,
)
result = cleaner.cleanup("all")
assert result["removed_bytes"] == 740
assert result["processed_files"] == 5
assert result["deleted_files"] == 3
assert result["truncated_files"] == 2
assert result["failed_files"] == 0
assert active_log.exists()
assert active_log.stat().st_size == 0
assert trace_log.exists()
assert trace_log.stat().st_size == 0
assert not rotated_log.exists()
assert not temp_file.exists()
assert not registry_cache.exists()
assert temp_dir.exists()
assert not (temp_dir / "nested").exists()
assert result["status"]["logs"]["size_bytes"] == 0
assert result["status"]["cache"]["size_bytes"] == 0