mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-01 01:10:21 +08:00
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:
271
astrbot/core/utils/storage_cleaner.py
Normal file
271
astrbot/core/utils/storage_cleaner.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
241
dashboard/src/components/shared/StorageCleanupPanel.vue
Normal file
241
dashboard/src/components/shared/StorageCleanupPanel.vue
Normal 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>
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "Ошибка копирования"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
85
tests/test_storage_cleaner.py
Normal file
85
tests/test_storage_cleaner.py
Normal 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
|
||||
Reference in New Issue
Block a user