diff --git a/astrbot/core/utils/storage_cleaner.py b/astrbot/core/utils/storage_cleaner.py new file mode 100644 index 0000000000..134071dce9 --- /dev/null +++ b/astrbot/core/utils/storage_cleaner.py @@ -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 diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index 532238ac7a..a6f7ff7f2d 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -1,3 +1,4 @@ +import asyncio import os import re import threading @@ -17,6 +18,7 @@ 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 @@ def __init__( "/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 @@ async def get_version(self): 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) diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 52a052cfa1..6117190cfc 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -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"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 85c2ed9fdf..32e3eea05b 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 376e47ce72..4b578d1d5c 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ diff --git a/dashboard/src/components/shared/StorageCleanupPanel.vue b/dashboard/src/components/shared/StorageCleanupPanel.vue new file mode 100644 index 0000000000..84aeaeb057 --- /dev/null +++ b/dashboard/src/components/shared/StorageCleanupPanel.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index 19232125f9..b497659ee4 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -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": { diff --git a/dashboard/src/i18n/locales/ru-RU/features/settings.json b/dashboard/src/i18n/locales/ru-RU/features/settings.json index 29b826fffe..d1100435f4 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/settings.json +++ b/dashboard/src/i18n/locales/ru-RU/features/settings.json @@ -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": "Ошибка копирования" } } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index 19c1c7c41e..092b4a5d9b 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -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": { diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index 8ec447dac2..9ddb4e1fec 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -1,6 +1,6 @@