Skip to content

Commit f52fe19

Browse files
feat(dashboard): export logs from the console and settings pages (#10418)
* feat(dashboard): export logs from the console and settings pages Add GET /api/v1/logs/export, which packs the log files and the in-memory logs into a zip archive. The files come from StorageCleaner, so log and trace log paths configured outside data/logs are included too, and the in-memory cache is split into memory/logs.txt and memory/traces.jsonl. Add an Export Logs button to the console page and to the log and cache cleanup panel in Settings. Closes #10407 * feat(dashboard): skip non-regular files and add a manifest to log exports Only pack regular files, so a symlink in a log directory cannot pull in a file from elsewhere and a FIFO cannot block the export. Skipped files and files that disappear while packing are listed in manifest.json with the reason, together with the AstrBot version and the in-memory log counts. Also keep same-named log files from different directories apart, and remove archives left behind by cancelled downloads. * fix(dashboard): harden log export against swapped files and disk use Open each log file without following symlinks or blocking on FIFOs and check the opened descriptor, so a file swapped after the lstat() check cannot get into the archive. Run one export at a time and check the free temp space before packing. Reserve the generated archive entries and dedupe every file name, move the temp directory setup into the handled block, and say in the tooltip and docs that without file logging the archive mostly has in-memory logs, since older log files on disk are still included. * feat(dashboard): unify diagnostic and log export --------- Co-authored-by: advent259141 <2968474907@qq.com>
1 parent f2404c1 commit f52fe19

20 files changed

Lines changed: 1413 additions & 17 deletions

File tree

‎astrbot/core/utils/storage_cleaner.py‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import os
4+
import stat
45
from collections.abc import Iterable, Mapping
56
from dataclasses import dataclass
67
from pathlib import Path
@@ -82,7 +83,7 @@ def cleanup(self, target: str = "all") -> dict:
8283

8384
def _build_status(self, target: str) -> dict:
8485
if target == self.TARGET_LOGS:
85-
files = self._collect_log_files()
86+
files = self.collect_log_files()
8687
primary_path = self._data_dir / "logs"
8788
elif target == self.TARGET_CACHE:
8889
files = self._collect_cache_files()
@@ -130,7 +131,7 @@ def _cleanup_target(self, target: str) -> dict:
130131
"failed_files": 0,
131132
}
132133
if target == self.TARGET_LOGS:
133-
files = self._collect_log_files()
134+
files = self.collect_log_files()
134135
active_log_files = self._active_log_files()
135136
elif target == self.TARGET_CACHE:
136137
files = self._collect_cache_files()
@@ -188,7 +189,13 @@ def _cleanup_target(self, target: str) -> dict:
188189
"failed_files": failed_files,
189190
}
190191

191-
def _collect_log_files(self) -> set[Path]:
192+
def collect_log_files(self) -> set[Path]:
193+
"""Collect files under data/logs plus the configured log files.
194+
195+
Returns:
196+
The log files, including rotated ones of the configured log and
197+
trace log paths, even when those are outside data/logs.
198+
"""
192199
files = set(self._iter_files(self._data_dir / "logs"))
193200
for log_path in self._configured_log_paths():
194201
files.update(self._iter_log_family_files(log_path))
@@ -208,10 +215,21 @@ def _collect_log_files(self) -> set[Path]:
208215
or path.name.endswith(("-wal", "-shm", "-journal"))
209216
):
210217
continue
218+
if path.is_symlink() or path.resolve() != path:
219+
candidates.add(path)
220+
continue
211221
try:
212222
# Protect custom database names, including an old active path
213223
# after a configuration edit that has not restarted the core.
214-
with path.open("rb") as stream:
224+
flags = (
225+
os.O_RDONLY
226+
| getattr(os, "O_NOFOLLOW", 0)
227+
| getattr(os, "O_NONBLOCK", 0)
228+
| getattr(os, "O_BINARY", 0)
229+
)
230+
with open(os.open(path, flags), "rb") as stream:
231+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
232+
continue
215233
if stream.read(16) == b"SQLite format 3\x00":
216234
continue
217235
except OSError:
@@ -281,8 +299,8 @@ def _resolve_log_path(
281299
path_value = configured_path or default_relative_path
282300
path = Path(path_value)
283301
if path.is_absolute():
284-
return path.resolve()
285-
return (self._data_dir / path).resolve()
302+
return Path(os.path.abspath(path))
303+
return Path(os.path.abspath(self._data_dir / path))
286304

287305
def _iter_log_family_files(self, log_path: Path) -> set[Path]:
288306
files: set[Path] = set()

‎astrbot/dashboard/api/app.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def create_dashboard_asgi_app(
126126
files=FileService(),
127127
knowledge_bases=KnowledgeBaseService(core_lifecycle),
128128
live_chat=LiveChatService(db, core_lifecycle),
129-
logs=LogService(log_broker, core_lifecycle.astrbot_config),
129+
logs=LogService(log_broker, core_lifecycle.astrbot_config, core=core_lifecycle),
130130
bots=BotConfigService(core_lifecycle),
131131
platforms=PlatformService(core_lifecycle),
132132
providers=ProviderConfigService(core_lifecycle),

‎astrbot/dashboard/api/logs.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from __future__ import annotations
22

3+
import asyncio
4+
import time
5+
36
from fastapi import APIRouter, Depends, Header, Path, Query, Request
4-
from fastapi.responses import StreamingResponse
7+
from fastapi.responses import FileResponse, StreamingResponse
8+
from starlette.background import BackgroundTask
59

610
from astrbot.dashboard.responses import ApiError, ok
711
from astrbot.dashboard.schemas import TraceSettingsRequest
@@ -80,6 +84,25 @@ async def live_logs(
8084
return _log_stream_response(last_event_id, service)
8185

8286

87+
@router.get("/logs/export")
88+
async def export_logs(
89+
redact: bool = Query(default=True),
90+
_username: str = Depends(require_dashboard_user),
91+
service: LogService = Depends(get_service),
92+
):
93+
try:
94+
archive_path = await asyncio.to_thread(service.export_logs, redact=redact)
95+
except LogServiceError as exc:
96+
_raise_log_error(exc)
97+
return FileResponse(
98+
archive_path,
99+
media_type="application/zip",
100+
filename=f"astrbot-diagnostics-{'raw-' if not redact else ''}{time.strftime('%Y%m%d-%H%M%S')}.zip",
101+
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
102+
background=BackgroundTask(archive_path.unlink, missing_ok=True),
103+
)
104+
105+
83106
@router.get("/trace/settings")
84107
async def get_trace_settings(
85108
_auth: AuthContext = Depends(require_system_scope),

0 commit comments

Comments
 (0)