feat(server): 新增系统日志服务模块,包含API端点、schema定义和服务实现,用于系统操作日志记录和查询
This commit is contained in:
102
server/src/app/api/v1/endpoints/system_logs.py
Normal file
102
server/src/app/api/v1/endpoints/system_logs.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import CurrentUserContext, require_admin_user
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.system_log import SystemLogEntryRead, SystemLogFileRead, SystemLogTailRead
|
||||
from app.services.system_logs import SystemLogService
|
||||
|
||||
router = APIRouter(prefix="/system-logs")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/entries",
|
||||
response_model=list[SystemLogEntryRead],
|
||||
summary="查询系统日志记录列表",
|
||||
description="解析 server/logs 下最近的日志内容,按单条日志记录返回,仅管理员可用。",
|
||||
responses={
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
"model": ErrorResponse,
|
||||
"description": "只有管理员可以查看系统日志。",
|
||||
}
|
||||
},
|
||||
)
|
||||
def list_system_log_entries(
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
limit: Annotated[int, Query(ge=20, le=1000, description="返回的日志记录数。")] = 300,
|
||||
) -> list[SystemLogEntryRead]:
|
||||
return SystemLogService().list_entries(entry_limit=limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/entries/{entry_id}",
|
||||
response_model=SystemLogEntryRead,
|
||||
summary="读取单条系统日志记录",
|
||||
description="按日志记录 ID 返回结构化解析结果,仅管理员可用。",
|
||||
responses={
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
"model": ErrorResponse,
|
||||
"description": "只有管理员可以查看系统日志。",
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "日志记录不存在。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_system_log_entry(
|
||||
entry_id: str,
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
) -> SystemLogEntryRead:
|
||||
try:
|
||||
return SystemLogService().get_entry(entry_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="日志记录不存在。") from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files",
|
||||
response_model=list[SystemLogFileRead],
|
||||
summary="查询系统日志文件列表",
|
||||
description="返回 server/logs 目录下可查看的日志文件列表,仅管理员可用。",
|
||||
responses={
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
"model": ErrorResponse,
|
||||
"description": "只有管理员可以查看系统日志。",
|
||||
}
|
||||
},
|
||||
)
|
||||
def list_system_log_files(
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
) -> list[SystemLogFileRead]:
|
||||
return SystemLogService().list_files()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_name}",
|
||||
response_model=SystemLogTailRead,
|
||||
summary="读取系统日志尾部内容",
|
||||
description="按文件名返回 server/logs 指定日志文件的最近若干行,仅管理员可用。",
|
||||
responses={
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
"model": ErrorResponse,
|
||||
"description": "只有管理员可以查看系统日志。",
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "日志文件不存在。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_system_log_tail(
|
||||
file_name: str,
|
||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||
lines: Annotated[int, Query(ge=20, le=1000, description="返回的日志行数。")] = 300,
|
||||
) -> SystemLogTailRead:
|
||||
try:
|
||||
return SystemLogService().read_tail(file_name, line_limit=lines)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="日志文件不存在。") from exc
|
||||
39
server/src/app/schemas/system_log.py
Normal file
39
server/src/app/schemas/system_log.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SystemLogFileRead(BaseModel):
|
||||
name: str
|
||||
size_bytes: int = 0
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class SystemLogTailRead(BaseModel):
|
||||
name: str
|
||||
size_bytes: int = 0
|
||||
updated_at: datetime | None = None
|
||||
line_count: int = 0
|
||||
lines: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemLogEntryRead(BaseModel):
|
||||
id: str
|
||||
source_file: str
|
||||
line_number: int = 0
|
||||
timestamp: datetime | None = None
|
||||
level: str = "UNKNOWN"
|
||||
logger: str = ""
|
||||
message: str = ""
|
||||
request_id: str = ""
|
||||
method: str = ""
|
||||
path: str = ""
|
||||
status_code: int | None = None
|
||||
duration_ms: float | None = None
|
||||
event_type: str = "系统日志"
|
||||
outcome: str = "未知"
|
||||
summary: str = ""
|
||||
parse_status: str = "parsed"
|
||||
raw: str = ""
|
||||
198
server/src/app/services/llm_wiki_tasks.py
Normal file
198
server/src/app/services/llm_wiki_tasks.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentRunStatus
|
||||
from app.core.logging import get_logger
|
||||
from app.db.session import get_session_factory
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_FAILED, KnowledgeService
|
||||
from app.services.llm_wiki import LlmWikiService
|
||||
|
||||
logger = get_logger("app.services.llm_wiki_tasks")
|
||||
|
||||
|
||||
class LlmWikiTaskManager:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._threads: dict[str, threading.Thread] = {}
|
||||
|
||||
def submit_sync(
|
||||
self,
|
||||
*,
|
||||
agent_run_id: str,
|
||||
folder: str,
|
||||
current_user: CurrentUserContext,
|
||||
document_ids: list[str] | None = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
worker = threading.Thread(
|
||||
target=self._run_sync,
|
||||
kwargs={
|
||||
"agent_run_id": agent_run_id,
|
||||
"folder": folder,
|
||||
"current_user": current_user,
|
||||
"document_ids": list(document_ids or []),
|
||||
"force": force,
|
||||
},
|
||||
daemon=True,
|
||||
name=f"llm-wiki-sync-{agent_run_id}",
|
||||
)
|
||||
with self._lock:
|
||||
self._threads[agent_run_id] = worker
|
||||
worker.start()
|
||||
|
||||
def shutdown(self, *, timeout_seconds: float = 1.0) -> None:
|
||||
with self._lock:
|
||||
threads = list(self._threads.items())
|
||||
self._threads.clear()
|
||||
|
||||
for _, worker in threads:
|
||||
if worker.is_alive():
|
||||
worker.join(timeout=timeout_seconds)
|
||||
|
||||
def _run_sync(
|
||||
self,
|
||||
*,
|
||||
agent_run_id: str,
|
||||
folder: str,
|
||||
current_user: CurrentUserContext,
|
||||
document_ids: list[str],
|
||||
force: bool,
|
||||
) -> None:
|
||||
session_factory = get_session_factory()
|
||||
db = session_factory()
|
||||
run_service = AgentRunService(db)
|
||||
knowledge_service = KnowledgeService()
|
||||
request_payload = {
|
||||
"folder": folder,
|
||||
"document_ids": list(document_ids),
|
||||
"force": force,
|
||||
}
|
||||
|
||||
try:
|
||||
run_service.merge_route_json(
|
||||
agent_run_id,
|
||||
{
|
||||
"phase": "running",
|
||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||
"job_type": "llm_wiki_sync",
|
||||
"folder": folder,
|
||||
"force": force,
|
||||
"requested_document_ids": list(document_ids),
|
||||
"progress": {
|
||||
"total_documents": len(document_ids),
|
||||
"completed_documents": 0,
|
||||
"failed_documents": 0,
|
||||
"skipped_documents": 0,
|
||||
"percent": 0,
|
||||
},
|
||||
},
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
result_summary="Hermes 后台归纳任务已启动。",
|
||||
)
|
||||
|
||||
result = LlmWikiService(db).sync_folder(
|
||||
folder=folder,
|
||||
current_user=current_user,
|
||||
document_ids=document_ids,
|
||||
force=force,
|
||||
agent_run_id=agent_run_id,
|
||||
progress_callback=lambda payload, summary: self._write_progress(
|
||||
run_service=run_service,
|
||||
agent_run_id=agent_run_id,
|
||||
payload=payload,
|
||||
summary=summary,
|
||||
),
|
||||
)
|
||||
run_service.record_tool_call(
|
||||
run_id=agent_run_id,
|
||||
tool_type="llm",
|
||||
tool_name="system_hermes_llm_wiki_sync",
|
||||
request_json=request_payload,
|
||||
response_json=result.model_dump(mode="json"),
|
||||
status="succeeded",
|
||||
duration_ms=0,
|
||||
)
|
||||
run_service.merge_route_json(
|
||||
agent_run_id,
|
||||
{
|
||||
"phase": "succeeded",
|
||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||
"sync_run_id": result.run_id,
|
||||
"sync_result": result.model_dump(mode="json"),
|
||||
"progress": {
|
||||
"total_documents": max(len(document_ids), result.document_count),
|
||||
"completed_documents": result.document_count,
|
||||
"failed_documents": 0,
|
||||
"skipped_documents": max(0, len(document_ids) - result.document_count),
|
||||
"percent": 100,
|
||||
},
|
||||
},
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result_summary=result.summary,
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Background LLM Wiki sync failed run_id=%s", agent_run_id)
|
||||
if document_ids:
|
||||
knowledge_service.set_document_ingest_statuses(
|
||||
document_ids,
|
||||
status_code=KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
agent_run_id=agent_run_id,
|
||||
)
|
||||
run_service.record_tool_call(
|
||||
run_id=agent_run_id,
|
||||
tool_type="llm",
|
||||
tool_name="system_hermes_llm_wiki_sync",
|
||||
request_json=request_payload,
|
||||
response_json={"error": str(exc)},
|
||||
status="failed",
|
||||
duration_ms=0,
|
||||
error_message=str(exc),
|
||||
)
|
||||
run_service.merge_route_json(
|
||||
agent_run_id,
|
||||
{
|
||||
"phase": "failed",
|
||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||
"progress": {
|
||||
"total_documents": len(document_ids),
|
||||
"completed_documents": 0,
|
||||
"failed_documents": len(document_ids),
|
||||
"skipped_documents": 0,
|
||||
"percent": 100,
|
||||
},
|
||||
},
|
||||
status=AgentRunStatus.FAILED.value,
|
||||
result_summary=str(exc),
|
||||
error_message=str(exc),
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
with self._lock:
|
||||
self._threads.pop(agent_run_id, None)
|
||||
|
||||
@staticmethod
|
||||
def _write_progress(
|
||||
*,
|
||||
run_service: AgentRunService,
|
||||
agent_run_id: str,
|
||||
payload: dict[str, Any],
|
||||
summary: str,
|
||||
) -> None:
|
||||
patched_payload = dict(payload)
|
||||
patched_payload["heartbeat_at"] = datetime.now(UTC).isoformat()
|
||||
run_service.merge_route_json(
|
||||
agent_run_id,
|
||||
patched_payload,
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
result_summary=summary,
|
||||
)
|
||||
|
||||
|
||||
llm_wiki_task_manager = LlmWikiTaskManager()
|
||||
250
server/src/app/services/system_logs.py
Normal file
250
server/src/app/services/system_logs.py
Normal file
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from app.core.config import SERVER_DIR
|
||||
from app.schemas.system_log import SystemLogEntryRead, SystemLogFileRead, SystemLogTailRead
|
||||
|
||||
|
||||
ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
|
||||
STRUCTURED_LINE_PATTERN = re.compile(
|
||||
r"^(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+\|\s+"
|
||||
r"(?P<level>[A-Z]+)\s+\|\s+(?P<logger>[^|]+?)\s+\|\s+(?P<message>.*)$"
|
||||
)
|
||||
HTTP_ACCESS_PATTERN = re.compile(
|
||||
r"^(?P<method>[A-Z]+)\s+(?P<path>\S+)\s+(?P<status_code>\d{3})\s+"
|
||||
r"(?P<duration_ms>\d+(?:\.\d+)?)ms(?:\s+request_id=(?P<request_id>\S+))?$"
|
||||
)
|
||||
|
||||
|
||||
class SystemLogService:
|
||||
def __init__(self, *, log_dir: Path | None = None) -> None:
|
||||
self.log_dir = Path(log_dir or (SERVER_DIR / "logs")).resolve()
|
||||
|
||||
def list_files(self) -> list[SystemLogFileRead]:
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
files = [
|
||||
self._serialize_file(path)
|
||||
for path in self.log_dir.iterdir()
|
||||
if path.is_file() and not path.name.startswith(".")
|
||||
]
|
||||
return sorted(
|
||||
files,
|
||||
key=lambda item: (item.updated_at or datetime.fromtimestamp(0, tz=UTC)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def read_tail(self, name: str, *, line_limit: int = 300) -> SystemLogTailRead:
|
||||
path = self._resolve_file(name)
|
||||
line_buffer: deque[str] = deque(maxlen=max(1, min(line_limit, 1000)))
|
||||
|
||||
with path.open("r", encoding="utf-8", errors="replace") as stream:
|
||||
for line in stream:
|
||||
line_buffer.append(line.rstrip("\n"))
|
||||
|
||||
file_meta = self._serialize_file(path)
|
||||
return SystemLogTailRead(
|
||||
name=file_meta.name,
|
||||
size_bytes=file_meta.size_bytes,
|
||||
updated_at=file_meta.updated_at,
|
||||
line_count=len(line_buffer),
|
||||
lines=list(line_buffer),
|
||||
)
|
||||
|
||||
def list_entries(self, *, entry_limit: int = 300, line_limit_per_file: int = 1200) -> list[SystemLogEntryRead]:
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
entries: list[SystemLogEntryRead] = []
|
||||
|
||||
for file_meta in self.list_files():
|
||||
path = self._resolve_file(file_meta.name)
|
||||
lines = self._read_tail_lines(path, line_limit=max(1, min(line_limit_per_file, 5000)))
|
||||
entries.extend(self._parse_entries(path.name, lines))
|
||||
|
||||
entries.sort(
|
||||
key=lambda item: (
|
||||
item.timestamp.timestamp() if item.timestamp else 0,
|
||||
item.source_file,
|
||||
item.line_number,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return entries[: max(1, min(entry_limit, 1000))]
|
||||
|
||||
def get_entry(self, entry_id: str) -> SystemLogEntryRead:
|
||||
normalized_id = str(entry_id or "").strip()
|
||||
if not normalized_id:
|
||||
raise FileNotFoundError("日志记录 ID 不能为空。")
|
||||
|
||||
for entry in self.list_entries(entry_limit=1000, line_limit_per_file=5000):
|
||||
if entry.id == normalized_id:
|
||||
return entry
|
||||
|
||||
raise FileNotFoundError(normalized_id)
|
||||
|
||||
def _resolve_file(self, name: str) -> Path:
|
||||
normalized_name = Path(str(name or "").strip()).name
|
||||
if not normalized_name:
|
||||
raise FileNotFoundError("日志文件名不能为空。")
|
||||
|
||||
path = (self.log_dir / normalized_name).resolve()
|
||||
if path.parent != self.log_dir or not path.exists() or not path.is_file():
|
||||
raise FileNotFoundError(normalized_name)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _read_tail_lines(path: Path, *, line_limit: int) -> list[tuple[int, str]]:
|
||||
line_buffer: deque[tuple[int, str]] = deque(maxlen=line_limit)
|
||||
with path.open("r", encoding="utf-8", errors="replace") as stream:
|
||||
for index, line in enumerate(stream, start=1):
|
||||
line_buffer.append((index, line.rstrip("\n")))
|
||||
return list(line_buffer)
|
||||
|
||||
def _parse_entries(self, source_file: str, lines: list[tuple[int, str]]) -> list[SystemLogEntryRead]:
|
||||
entries: list[SystemLogEntryRead] = []
|
||||
current: dict[str, object] | None = None
|
||||
|
||||
for line_number, raw_line in lines:
|
||||
clean_line = ANSI_PATTERN.sub("", raw_line)
|
||||
match = STRUCTURED_LINE_PATTERN.match(clean_line)
|
||||
|
||||
if match:
|
||||
if current is not None:
|
||||
entries.append(self._build_entry(source_file, current))
|
||||
current = {
|
||||
"line_number": line_number,
|
||||
"timestamp": self._parse_timestamp(match.group("timestamp")),
|
||||
"level": match.group("level").strip(),
|
||||
"logger": match.group("logger").strip(),
|
||||
"message": match.group("message").strip(),
|
||||
"raw_lines": [clean_line],
|
||||
"parse_status": "parsed",
|
||||
}
|
||||
continue
|
||||
|
||||
if current is not None:
|
||||
current["raw_lines"].append(clean_line)
|
||||
continue
|
||||
|
||||
if clean_line.strip():
|
||||
current = {
|
||||
"line_number": line_number,
|
||||
"timestamp": None,
|
||||
"level": "UNKNOWN",
|
||||
"logger": "",
|
||||
"message": clean_line.strip(),
|
||||
"raw_lines": [clean_line],
|
||||
"parse_status": "unparsed",
|
||||
}
|
||||
entries.append(self._build_entry(source_file, current))
|
||||
current = None
|
||||
|
||||
if current is not None:
|
||||
entries.append(self._build_entry(source_file, current))
|
||||
|
||||
return entries
|
||||
|
||||
def _build_entry(self, source_file: str, payload: dict[str, object]) -> SystemLogEntryRead:
|
||||
message = str(payload["message"])
|
||||
logger = str(payload["logger"])
|
||||
level = str(payload["level"])
|
||||
http_match = HTTP_ACCESS_PATTERN.match(message)
|
||||
method = http_match.group("method") if http_match else ""
|
||||
path = http_match.group("path") if http_match else ""
|
||||
status_code = int(http_match.group("status_code")) if http_match else None
|
||||
duration_ms = float(http_match.group("duration_ms")) if http_match else None
|
||||
request_id = http_match.group("request_id") if http_match and http_match.group("request_id") else ""
|
||||
event_type = self._resolve_event_type(logger, level, http_match is not None)
|
||||
outcome = self._resolve_outcome(level, status_code)
|
||||
summary = self._build_summary(
|
||||
event_type=event_type,
|
||||
message=message,
|
||||
method=method,
|
||||
path=path,
|
||||
status_code=status_code,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
raw = "\n".join(str(line) for line in payload["raw_lines"])
|
||||
fingerprint = f"{source_file}:{payload['line_number']}:{payload['timestamp']}:{raw}"
|
||||
entry_id = hashlib.sha1(fingerprint.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
return SystemLogEntryRead(
|
||||
id=entry_id,
|
||||
source_file=source_file,
|
||||
line_number=int(payload["line_number"]),
|
||||
timestamp=payload["timestamp"],
|
||||
level=level,
|
||||
logger=logger,
|
||||
message=message,
|
||||
request_id=request_id,
|
||||
method=method,
|
||||
path=path,
|
||||
status_code=status_code,
|
||||
duration_ms=duration_ms,
|
||||
event_type=event_type,
|
||||
outcome=outcome,
|
||||
summary=summary,
|
||||
parse_status=str(payload["parse_status"]),
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp(value: str) -> datetime | None:
|
||||
try:
|
||||
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_event_type(logger: str, level: str, is_http_access: bool) -> str:
|
||||
if is_http_access or logger == "app.middleware.access":
|
||||
return "HTTP 请求"
|
||||
if level in {"ERROR", "CRITICAL"}:
|
||||
return "系统异常"
|
||||
if level == "WARNING":
|
||||
return "系统告警"
|
||||
return "运行日志"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_outcome(level: str, status_code: int | None) -> str:
|
||||
if status_code is not None:
|
||||
if status_code >= 500:
|
||||
return "失败"
|
||||
if status_code >= 400:
|
||||
return "异常"
|
||||
return "成功"
|
||||
if level in {"ERROR", "CRITICAL"}:
|
||||
return "失败"
|
||||
if level == "WARNING":
|
||||
return "告警"
|
||||
if level in {"INFO", "DEBUG"}:
|
||||
return "成功"
|
||||
return "未知"
|
||||
|
||||
@staticmethod
|
||||
def _build_summary(
|
||||
*,
|
||||
event_type: str,
|
||||
message: str,
|
||||
method: str,
|
||||
path: str,
|
||||
status_code: int | None,
|
||||
duration_ms: float | None,
|
||||
) -> str:
|
||||
if method and path and status_code is not None:
|
||||
return f"{method} {path} 返回 {status_code},耗时 {duration_ms or 0:.1f}ms"
|
||||
if len(message) <= 96:
|
||||
return message
|
||||
return f"{message[:96]}..."
|
||||
|
||||
@staticmethod
|
||||
def _serialize_file(path: Path) -> SystemLogFileRead:
|
||||
stat = path.stat()
|
||||
return SystemLogFileRead(
|
||||
name=path.name,
|
||||
size_bytes=stat.st_size,
|
||||
updated_at=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
|
||||
)
|
||||
54
server/tests/test_system_logs_service.py
Normal file
54
server/tests/test_system_logs_service.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.system_logs import SystemLogService
|
||||
|
||||
|
||||
def test_system_log_service_reads_tail(tmp_path) -> None:
|
||||
log_dir = tmp_path / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "app.log"
|
||||
log_file.write_text(
|
||||
"\n".join(f"line-{index}" for index in range(1, 21)) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
service = SystemLogService(log_dir=log_dir)
|
||||
files = service.list_files()
|
||||
tail = service.read_tail("app.log", line_limit=5)
|
||||
|
||||
assert [item.name for item in files] == ["app.log"]
|
||||
assert tail.name == "app.log"
|
||||
assert tail.line_count == 5
|
||||
assert tail.lines == ["line-16", "line-17", "line-18", "line-19", "line-20"]
|
||||
|
||||
|
||||
def test_system_log_service_parses_entries(tmp_path) -> None:
|
||||
log_dir = tmp_path / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "app.log"
|
||||
log_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"2026-05-15 09:00:00 | INFO | app.middleware.access | GET /api/v1/health 200 8.2ms request_id=req_1",
|
||||
"2026-05-15 09:00:01 | WARNING | app.services.settings | Skipping undecryptable model API key",
|
||||
"2026-05-15 09:00:02 | ERROR | app.services.demo | Failed to load plugin",
|
||||
"Traceback line 1",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
service = SystemLogService(log_dir=log_dir)
|
||||
entries = service.list_entries(entry_limit=10)
|
||||
|
||||
assert len(entries) == 3
|
||||
assert entries[0].level == "ERROR"
|
||||
assert entries[0].event_type == "系统异常"
|
||||
assert "Traceback line 1" in entries[0].raw
|
||||
assert entries[1].outcome == "告警"
|
||||
assert entries[2].event_type == "HTTP 请求"
|
||||
assert entries[2].method == "GET"
|
||||
assert entries[2].status_code == 200
|
||||
assert entries[2].request_id == "req_1"
|
||||
assert service.get_entry(entries[2].id).id == entries[2].id
|
||||
Reference in New Issue
Block a user