mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 19:53:11 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fcda537a3 | ||
|
|
3ca94be7b7 | ||
|
|
eb70692843 | ||
|
|
3d20623b41 | ||
|
|
2c45b3baa8 | ||
|
|
0cd2215f9d |
10
backend/apps/common/services/__init__.py
Normal file
10
backend/apps/common/services/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
通用服务模块
|
||||
|
||||
提供系统级别的公共服务,包括:
|
||||
- SystemLogService: 系统日志读取服务
|
||||
"""
|
||||
|
||||
from .system_log_service import SystemLogService
|
||||
|
||||
__all__ = ['SystemLogService']
|
||||
127
backend/apps/common/services/system_log_service.py
Normal file
127
backend/apps/common/services/system_log_service.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
系统日志服务模块
|
||||
|
||||
提供系统日志的读取和处理功能,支持:
|
||||
- 从多个日志目录读取日志文件
|
||||
- 按时间戳排序日志条目
|
||||
- 限制返回行数,防止内存溢出
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemLogService:
|
||||
"""
|
||||
系统日志服务类
|
||||
|
||||
负责读取和处理系统日志文件,支持从容器内路径或宿主机挂载路径读取日志。
|
||||
日志会按时间戳排序后返回,支持 JSON 格式的结构化日志解析。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 日志文件搜索路径(按优先级排序,找到第一个有效路径后停止)
|
||||
self.log_globs = [
|
||||
"/app/backend/logs/*", # Docker 容器内路径
|
||||
"/opt/xingrin/logs/*", # 宿主机挂载路径
|
||||
]
|
||||
self.default_lines = 200 # 默认返回行数
|
||||
self.max_lines = 10000 # 最大返回行数限制
|
||||
self.timeout_seconds = 3 # tail 命令超时时间
|
||||
|
||||
def get_logs_content(self, lines: int | None = None) -> str:
|
||||
"""
|
||||
获取系统日志内容
|
||||
|
||||
Args:
|
||||
lines: 返回的日志行数,默认 200 行,最大 10000 行
|
||||
|
||||
Returns:
|
||||
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 ""
|
||||
|
||||
# 使用 tail 命令读取日志文件末尾内容
|
||||
# -q: 静默模式,不输出文件名头
|
||||
# -n: 指定读取行数
|
||||
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()]
|
||||
|
||||
# 解析日志行,提取时间戳用于排序
|
||||
# 支持 JSON 格式日志(包含 asctime 字段)和方括号格式 [2025-12-17 17:09:06]
|
||||
parsed: list[tuple[datetime | None, int, str]] = []
|
||||
for idx, line in enumerate(raw_lines):
|
||||
ts: datetime | None = None
|
||||
# 尝试解析 JSON 格式日志
|
||||
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
|
||||
# 尝试解析方括号格式日志: [2025-12-17 17:09:06]
|
||||
elif line.startswith("["):
|
||||
try:
|
||||
# 提取第一个方括号内的时间戳
|
||||
end_bracket = line.find("]")
|
||||
if end_bracket > 1:
|
||||
time_str = line[1:end_bracket]
|
||||
ts = datetime.strptime(time_str, "%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]
|
||||
|
||||
# 截取最后 N 行
|
||||
if len(sorted_lines) > lines:
|
||||
sorted_lines = sorted_lines[-lines:]
|
||||
|
||||
return "\n".join(sorted_lines) + ("\n" if sorted_lines else "")
|
||||
@@ -1,12 +1,21 @@
|
||||
"""
|
||||
通用模块 URL 配置
|
||||
|
||||
路由说明:
|
||||
- /api/auth/* 认证相关接口(登录、登出、用户信息)
|
||||
- /api/system/* 系统管理接口(日志查看等)
|
||||
"""
|
||||
|
||||
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'),
|
||||
]
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
from .auth_views import LoginView, LogoutView, MeView, ChangePasswordView
|
||||
"""
|
||||
通用模块视图导出
|
||||
|
||||
__all__ = ['LoginView', 'LogoutView', 'MeView', 'ChangePasswordView']
|
||||
包含:
|
||||
- 认证相关视图:登录、登出、用户信息、修改密码
|
||||
- 系统日志视图:实时日志查看
|
||||
"""
|
||||
|
||||
from .auth_views import LoginView, LogoutView, MeView, ChangePasswordView
|
||||
from .system_log_views import SystemLogsView
|
||||
|
||||
__all__ = ['LoginView', 'LogoutView', 'MeView', 'ChangePasswordView', 'SystemLogsView']
|
||||
|
||||
69
backend/apps/common/views/system_log_views.py
Normal file
69
backend/apps/common/views/system_log_views.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
系统日志视图模块
|
||||
|
||||
提供系统日志的 REST API 接口,供前端实时查看系统运行日志。
|
||||
"""
|
||||
|
||||
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):
|
||||
"""
|
||||
系统日志 API 视图
|
||||
|
||||
GET /api/system/logs/
|
||||
获取系统日志内容
|
||||
|
||||
Query Parameters:
|
||||
lines (int, optional): 返回的日志行数,默认 200,最大 10000
|
||||
|
||||
Response:
|
||||
{
|
||||
"content": "日志内容字符串..."
|
||||
}
|
||||
|
||||
Note:
|
||||
- 当前为开发阶段,暂时允许匿名访问
|
||||
- 生产环境应添加管理员权限验证
|
||||
"""
|
||||
|
||||
# TODO: 生产环境应改为 IsAdminUser 权限
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.service = SystemLogService()
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
获取系统日志
|
||||
|
||||
支持通过 lines 参数控制返回行数,用于前端分页或实时刷新场景。
|
||||
"""
|
||||
try:
|
||||
# 解析 lines 参数
|
||||
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)
|
||||
@@ -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} 服务已启动"
|
||||
|
||||
15
frontend/app/settings/system-logs/page.tsx
Normal file
15
frontend/app/settings/system-logs/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { SystemLogsView } from "@/components/settings/system-logs"
|
||||
|
||||
export default function SystemLogsPage() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">系统日志</h1>
|
||||
<p className="text-muted-foreground">每秒自动刷新一次(轮询模式)</p>
|
||||
</div>
|
||||
<SystemLogsView />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IconTool, // 工具图标
|
||||
IconFlask, // 实验瓶图标
|
||||
IconServer, // 服务器图标
|
||||
IconTerminal2, // 终端图标
|
||||
IconBug, // 漏洞图标
|
||||
} from "@tabler/icons-react"
|
||||
// 导入路径名 hook
|
||||
@@ -130,6 +131,11 @@ const data = {
|
||||
url: "/settings/workers/",
|
||||
icon: IconServer,
|
||||
},
|
||||
{
|
||||
name: "系统日志",
|
||||
url: "/settings/system-logs/",
|
||||
icon: IconTerminal2,
|
||||
},
|
||||
{
|
||||
name: "通知设置", // 通知设置
|
||||
url: "/settings/notifications/",
|
||||
|
||||
1
frontend/components/settings/system-logs/index.ts
Normal file
1
frontend/components/settings/system-logs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SystemLogsView } from "./system-logs-view"
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import Editor from "@monaco-editor/react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { useSystemLogs } from "@/hooks/use-system-logs"
|
||||
|
||||
export function SystemLogsView() {
|
||||
const { theme } = useTheme()
|
||||
const { data } = useSystemLogs({ lines: 200 })
|
||||
|
||||
const content = useMemo(() => data?.content ?? "", [data?.content])
|
||||
|
||||
const editorRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
const model = editor.getModel?.()
|
||||
if (!model) return
|
||||
|
||||
const lastLine = model.getLineCount?.() ?? 1
|
||||
editor.revealLine?.(lastLine)
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="h-[calc(100vh-240px)] min-h-[360px] rounded-lg border overflow-hidden">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="log"
|
||||
value={content || "(暂无日志内容)"}
|
||||
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
}}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "off",
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
folding: false,
|
||||
wordWrap: "off",
|
||||
renderLineHighlight: "none",
|
||||
padding: { top: 12, bottom: 12 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
32
frontend/hooks/use-system-logs.ts
Normal file
32
frontend/hooks/use-system-logs.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { systemLogService } from "@/services/system-log.service"
|
||||
|
||||
export function useSystemLogs(options?: { lines?: number; enabled?: boolean }) {
|
||||
const hadErrorRef = useRef(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["system", "logs", { lines: options?.lines ?? null }],
|
||||
queryFn: () => systemLogService.getSystemLogs({ lines: options?.lines }),
|
||||
enabled: options?.enabled ?? true,
|
||||
refetchInterval: 1000,
|
||||
refetchIntervalInBackground: true,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError && !hadErrorRef.current) {
|
||||
hadErrorRef.current = true
|
||||
toast.error("系统日志获取失败,请检查后端接口")
|
||||
}
|
||||
|
||||
if (query.isSuccess && hadErrorRef.current) {
|
||||
hadErrorRef.current = false
|
||||
toast.success("系统日志连接已恢复")
|
||||
}
|
||||
}, [query.isError, query.isSuccess])
|
||||
|
||||
return query
|
||||
}
|
||||
20
frontend/services/system-log.service.ts
Normal file
20
frontend/services/system-log.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import apiClient from "@/lib/api-client"
|
||||
import type { SystemLogResponse } from "@/types/system-log.types"
|
||||
|
||||
const BASE_URL = "/system/logs"
|
||||
|
||||
export const systemLogService = {
|
||||
async getSystemLogs(params?: { lines?: number }): Promise<SystemLogResponse> {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params?.lines != null) {
|
||||
searchParams.set("lines", String(params.lines))
|
||||
}
|
||||
|
||||
const query = searchParams.toString()
|
||||
const url = query ? `${BASE_URL}/?${query}` : `${BASE_URL}/`
|
||||
|
||||
const response = await apiClient.get<SystemLogResponse>(url)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
3
frontend/types/system-log.types.ts
Normal file
3
frontend/types/system-log.types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface SystemLogResponse {
|
||||
content: string
|
||||
}
|
||||
15
install.sh
15
install.sh
@@ -388,6 +388,21 @@ fi
|
||||
# 准备 HTTPS 证书(无域名也可使用自签)
|
||||
generate_self_signed_cert
|
||||
|
||||
# ==============================================================================
|
||||
# 预拉取 Worker 镜像(避免扫描时等待)
|
||||
# ==============================================================================
|
||||
step "预拉取 Worker 镜像..."
|
||||
DOCKER_USER=$(grep "^DOCKER_USER=" "$DOCKER_DIR/.env" 2>/dev/null | cut -d= -f2)
|
||||
DOCKER_USER=${DOCKER_USER:-yyhuni}
|
||||
WORKER_IMAGE="${DOCKER_USER}/xingrin-worker:${APP_VERSION}"
|
||||
|
||||
info "正在拉取: $WORKER_IMAGE"
|
||||
if docker pull "$WORKER_IMAGE"; then
|
||||
success "Worker 镜像拉取完成"
|
||||
else
|
||||
warn "Worker 镜像拉取失败,扫描时会自动重试拉取"
|
||||
fi
|
||||
|
||||
# ==============================================================================
|
||||
# 启动服务
|
||||
# ==============================================================================
|
||||
|
||||
@@ -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 "已保留日志和结果目录。"
|
||||
|
||||
Reference in New Issue
Block a user