diff --git a/backend/apps/common/services/__init__.py b/backend/apps/common/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/common/services/system_log_service.py b/backend/apps/common/services/system_log_service.py new file mode 100644 index 00000000..946ee159 --- /dev/null +++ b/backend/apps/common/services/system_log_service.py @@ -0,0 +1,78 @@ +import glob +import json +import logging +import subprocess +from datetime import datetime + + +logger = logging.getLogger(__name__) + + +class SystemLogService: + def __init__(self): + self.log_globs = [ + "/app/backend/logs/*", + "/opt/xingrin/logs/*", + ] + self.default_lines = 200 + self.max_lines = 10000 + self.timeout_seconds = 3 + + def get_logs_content(self, lines: int | None = None) -> str: + if lines is None: + lines = self.default_lines + + lines = int(lines) + if lines < 1: + lines = 1 + if lines > self.max_lines: + lines = self.max_lines + + files: list[str] = [] + for pattern in self.log_globs: + matched = sorted(glob.glob(pattern)) + if matched: + files = matched + break + if not files: + return "" + + cmd = ["tail", "-q", "-n", str(lines), *files] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.timeout_seconds, + check=False, + ) + + if result.returncode != 0: + logger.warning( + "tail command failed: returncode=%s stderr=%s", + result.returncode, + (result.stderr or "").strip(), + ) + + raw = result.stdout or "" + raw_lines = [ln for ln in raw.splitlines() if ln.strip()] + + parsed: list[tuple[datetime | None, int, str]] = [] + for idx, line in enumerate(raw_lines): + ts: datetime | None = None + if line.startswith("{") and line.endswith("}"): + try: + obj = json.loads(line) + asctime = obj.get("asctime") + if isinstance(asctime, str): + ts = datetime.strptime(asctime, "%Y-%m-%d %H:%M:%S") + except Exception: + ts = None + parsed.append((ts, idx, line)) + + parsed.sort(key=lambda x: (x[0] is None, x[0] or datetime.min, x[1])) + sorted_lines = [x[2] for x in parsed] + if len(sorted_lines) > lines: + sorted_lines = sorted_lines[-lines:] + + return "\n".join(sorted_lines) + ("\n" if sorted_lines else "") diff --git a/backend/apps/common/urls.py b/backend/apps/common/urls.py index 65d4cfde..afcb2114 100644 --- a/backend/apps/common/urls.py +++ b/backend/apps/common/urls.py @@ -2,11 +2,12 @@ 通用模块 URL 配置 """ from django.urls import path -from .views import LoginView, LogoutView, MeView, ChangePasswordView +from .views import LoginView, LogoutView, MeView, ChangePasswordView, SystemLogsView urlpatterns = [ path('auth/login/', LoginView.as_view(), name='auth-login'), path('auth/logout/', LogoutView.as_view(), name='auth-logout'), path('auth/me/', MeView.as_view(), name='auth-me'), path('auth/change-password/', ChangePasswordView.as_view(), name='auth-change-password'), + path('system/logs/', SystemLogsView.as_view(), name='system-logs'), ] diff --git a/backend/apps/common/views/__init__.py b/backend/apps/common/views/__init__.py index 05115be3..1c5fc296 100644 --- a/backend/apps/common/views/__init__.py +++ b/backend/apps/common/views/__init__.py @@ -1,3 +1,4 @@ from .auth_views import LoginView, LogoutView, MeView, ChangePasswordView +from .system_log_views import SystemLogsView -__all__ = ['LoginView', 'LogoutView', 'MeView', 'ChangePasswordView'] +__all__ = ['LoginView', 'LogoutView', 'MeView', 'ChangePasswordView', 'SystemLogsView'] diff --git a/backend/apps/common/views/system_log_views.py b/backend/apps/common/views/system_log_views.py new file mode 100644 index 00000000..70e8da29 --- /dev/null +++ b/backend/apps/common/views/system_log_views.py @@ -0,0 +1,36 @@ +import logging + +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.common.services.system_log_service import SystemLogService + + +logger = logging.getLogger(__name__) + + +@method_decorator(csrf_exempt, name="dispatch") +class SystemLogsView(APIView): + authentication_classes = [] + permission_classes = [AllowAny] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.service = SystemLogService() + + def get(self, request): + try: + lines_raw = request.query_params.get("lines") + lines = int(lines_raw) if lines_raw is not None else None + + content = self.service.get_logs_content(lines=lines) + return Response({"content": content}) + except ValueError: + return Response({"error": "lines 参数必须是整数"}, status=status.HTTP_400_BAD_REQUEST) + except Exception: + logger.exception("获取系统日志失败") + return Response({"error": "获取系统日志失败"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/docker/start.sh b/docker/start.sh index 4943ef3a..b684fd76 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -85,38 +85,68 @@ export BUILDKIT_INLINE_CACHE=1 # 使用指定的 compose 文件 COMPOSE_ARGS="-f ${COMPOSE_FILE} ${PROFILE_ARG}" +SERVICES="$(${COMPOSE_CMD} ${COMPOSE_ARGS} config --services)" +service_exists() { + echo "$SERVICES" | grep -qx "$1" +} + +BACKEND_SERVICES=() +for s in redis server agent; do + if service_exists "$s"; then + BACKEND_SERVICES+=("$s") + fi +done + +# 如果使用本地数据库,先启动 postgres 并等待健康 +start_postgres_first() { + if [ -n "$PROFILE_ARG" ]; then + echo -e "${CYAN}[DB]${NC} 启动 PostgreSQL 容器..." + ${COMPOSE_CMD} ${COMPOSE_ARGS} up -d postgres + echo -e "${CYAN}[DB]${NC} 等待 PostgreSQL 就绪..." + local max_wait=30 + local count=0 + while [ $count -lt $max_wait ]; do + if ${COMPOSE_CMD} ${COMPOSE_ARGS} exec -T postgres pg_isready -U postgres >/dev/null 2>&1; then + echo -e "${GREEN}[DB]${NC} PostgreSQL 已就绪" + return 0 + fi + sleep 1 + count=$((count + 1)) + done + echo -e "${YELLOW}[WARN]${NC} PostgreSQL 等待超时,继续启动其他服务..." + fi +} + echo "" if [ "$DEV_MODE" = true ]; then # 开发模式:本地构建 if [ "$WITH_FRONTEND" = true ]; then echo -e "${CYAN}[BUILD]${NC} 并行构建镜像..." ${COMPOSE_CMD} ${COMPOSE_ARGS} build --parallel + start_postgres_first echo -e "${CYAN}[START]${NC} 启动全部服务..." ${COMPOSE_CMD} ${COMPOSE_ARGS} up -d else echo -e "${CYAN}[BUILD]${NC} 并行构建后端镜像..." - ${COMPOSE_CMD} ${COMPOSE_ARGS} build --parallel server scan-worker maintenance-worker + ${COMPOSE_CMD} ${COMPOSE_ARGS} build --parallel "${BACKEND_SERVICES[@]}" + start_postgres_first echo -e "${CYAN}[START]${NC} 启动后端服务..." - ${COMPOSE_CMD} ${COMPOSE_ARGS} up -d redis server scan-worker maintenance-worker - if [ -n "$PROFILE_ARG" ]; then - ${COMPOSE_CMD} ${COMPOSE_ARGS} up -d postgres - fi + ${COMPOSE_CMD} ${COMPOSE_ARGS} up -d "${BACKEND_SERVICES[@]}" fi else # 生产模式:拉取 Docker Hub 镜像 if [ "$WITH_FRONTEND" = true ]; then echo -e "${CYAN}[PULL]${NC} 拉取最新镜像..." ${COMPOSE_CMD} ${COMPOSE_ARGS} pull + start_postgres_first echo -e "${CYAN}[START]${NC} 启动全部服务..." ${COMPOSE_CMD} ${COMPOSE_ARGS} up -d else echo -e "${CYAN}[PULL]${NC} 拉取后端镜像..." - ${COMPOSE_CMD} ${COMPOSE_ARGS} pull redis server scan-worker maintenance-worker + ${COMPOSE_CMD} ${COMPOSE_ARGS} pull "${BACKEND_SERVICES[@]}" + start_postgres_first echo -e "${CYAN}[START]${NC} 启动后端服务..." - ${COMPOSE_CMD} ${COMPOSE_ARGS} up -d redis server scan-worker maintenance-worker - if [ -n "$PROFILE_ARG" ]; then - ${COMPOSE_CMD} ${COMPOSE_ARGS} up -d postgres - fi + ${COMPOSE_CMD} ${COMPOSE_ARGS} up -d "${BACKEND_SERVICES[@]}" fi fi echo -e "${GREEN}[OK]${NC} 服务已启动" diff --git a/frontend/components/settings/system-logs/system-logs-view.tsx b/frontend/components/settings/system-logs/system-logs-view.tsx index c5621def..9632fbf0 100644 --- a/frontend/components/settings/system-logs/system-logs-view.tsx +++ b/frontend/components/settings/system-logs/system-logs-view.tsx @@ -9,7 +9,7 @@ import { useSystemLogs } from "@/hooks/use-system-logs" export function SystemLogsView() { const { theme } = useTheme() - const { data } = useSystemLogs({ lines: 2000 }) + const { data } = useSystemLogs({ lines: 200 }) const content = useMemo(() => data?.content ?? "", [data?.content]) diff --git a/uninstall.sh b/uninstall.sh index 323e37d3..01aff6bb 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -95,16 +95,17 @@ fi # ============================================================================== # 2. 删除扫描日志和结果目录 # ============================================================================== -LOGS_DIR="$ROOT_DIR/backend/logs" -RESULTS_DIR="$ROOT_DIR/backend/results" -step "[2/6] 是否删除扫描日志和结果目录 ($LOGS_DIR, $RESULTS_DIR)?(Y/n)" +OPT_LOGS_DIR="/opt/xingrin/logs" +OPT_RESULTS_DIR="/opt/xingrin/results" + +step "[2/6] 是否删除扫描日志和结果目录 ($OPT_LOGS_DIR, $OPT_RESULTS_DIR)?(Y/n)" read -r ans_logs ans_logs=${ans_logs:-Y} if [[ $ans_logs =~ ^[Yy]$ ]]; then info "正在删除日志和结果目录..." - rm -rf "$LOGS_DIR" "$RESULTS_DIR" + rm -rf "$OPT_LOGS_DIR" "$OPT_RESULTS_DIR" success "已删除日志和结果目录。" else warn "已保留日志和结果目录。"