Add log system with three log types (agent/system/chat)
Implemented a complete log system for tracking: - Agent logs:智能体调用 - System logs: 系统运行 - Chat logs: 问答对话 Backend: - Log model with type, level, user_id, message, source, duration_ms - LogService with methods for logging and querying - API endpoints: GET /api/logs, GET /api/logs/stats, GET /api/logs/recent Frontend: - LogView.vue with filters, stats, pagination, auto-refresh - log.ts API client with TypeScript interfaces - Added "运行日志" nav item to sidebar
This commit is contained in:
@@ -14,6 +14,7 @@ from app.routers import (
|
||||
settings_router,
|
||||
folder_router,
|
||||
skill_router,
|
||||
log_router,
|
||||
)
|
||||
from app.routers.scheduler import router as scheduler_router
|
||||
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
||||
@@ -61,6 +62,7 @@ app.include_router(todo_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(folder_router)
|
||||
app.include_router(skill_router)
|
||||
app.include_router(log_router)
|
||||
app.include_router(scheduler_router)
|
||||
|
||||
|
||||
|
||||
33
backend/app/models/log.py
Normal file
33
backend/app/models/log.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from sqlalchemy import Column, String, Text, DateTime, Index, Enum as SQLEnum
|
||||
from app.models.base import BaseModel
|
||||
import enum
|
||||
|
||||
|
||||
class LogLevel(str, enum.Enum):
|
||||
DEBUG = "debug"
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class LogType(str, enum.Enum):
|
||||
AGENT = "agent" # 智能体调用
|
||||
SYSTEM = "system" # 系统运行
|
||||
CHAT = "chat" # 问答对话
|
||||
|
||||
|
||||
class Log(BaseModel):
|
||||
__tablename__ = "logs"
|
||||
|
||||
level = Column(String(20), default=LogLevel.INFO.value, index=True) # debug/info/warning/error
|
||||
type = Column(String(20), default=LogType.SYSTEM.value, index=True) # agent/system/chat
|
||||
user_id = Column(String(36), nullable=True, index=True) # 关联用户
|
||||
message = Column(Text, nullable=False) # 日志内容
|
||||
details = Column(Text, nullable=True) # 详细信息(JSON)
|
||||
source = Column(String(100), nullable=True) # 来源模块
|
||||
duration_ms = Column(String(20), nullable=True) # 执行耗时
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_logs_type_level', 'type', 'level'),
|
||||
Index('idx_logs_created_at', 'created_at'),
|
||||
)
|
||||
@@ -9,3 +9,4 @@ from app.routers.todo import router as todo_router
|
||||
from app.routers.settings import router as settings_router
|
||||
from app.routers.folder import router as folder_router
|
||||
from app.routers.skill import router as skill_router
|
||||
from app.routers.log import router as log_router
|
||||
|
||||
93
backend/app/routers/log.py
Normal file
93
backend/app/routers/log.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.services.log_service import LogService
|
||||
|
||||
router = APIRouter(prefix="/api/logs", tags=["Log"])
|
||||
|
||||
|
||||
class LogOut(BaseModel):
|
||||
id: str
|
||||
level: str
|
||||
type: str
|
||||
user_id: Optional[str]
|
||||
message: str
|
||||
source: Optional[str]
|
||||
details: Optional[str]
|
||||
duration_ms: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class LogStatsOut(BaseModel):
|
||||
total: int
|
||||
by_type: dict
|
||||
by_level: dict
|
||||
|
||||
|
||||
class LogQueryOut(BaseModel):
|
||||
logs: list[LogOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
@router.get("", response_model=LogQueryOut)
|
||||
async def list_logs(
|
||||
log_type: Optional[str] = Query(None, description="日志类型: agent/system/chat"),
|
||||
level: Optional[str] = Query(None, description="日志级别: debug/info/warning/error"),
|
||||
source: Optional[str] = Query(None, description="来源模块"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询日志列表"""
|
||||
svc = LogService(db)
|
||||
offset = (page - 1) * page_size
|
||||
logs, total = await svc.list_logs(
|
||||
log_type=log_type,
|
||||
level=level,
|
||||
user_id=current_user.id,
|
||||
source=source,
|
||||
limit=page_size,
|
||||
offset=offset,
|
||||
)
|
||||
return LogQueryOut(
|
||||
logs=[LogOut.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=LogStatsOut)
|
||||
async def get_log_stats(
|
||||
hours: int = Query(24, ge=1, le=168),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取日志统计"""
|
||||
svc = LogService(db)
|
||||
stats = await svc.get_log_stats(hours=hours)
|
||||
return LogStatsOut(**stats)
|
||||
|
||||
|
||||
@router.get("/recent", response_model=list[LogOut])
|
||||
async def get_recent_logs(
|
||||
log_type: Optional[str] = Query(None),
|
||||
hours: int = Query(24, ge=1, le=168),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取最近的日志"""
|
||||
svc = LogService(db)
|
||||
logs = await svc.get_recent_logs(log_type=log_type, hours=hours, limit=limit)
|
||||
return [LogOut.model_validate(log) for log in logs]
|
||||
262
backend/app/services/log_service.py
Normal file
262
backend/app/services/log_service.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
运行日志服务
|
||||
提供统一的日志记录接口,支持分类存储和查询
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, desc, func
|
||||
from app.models.log import Log, LogType, LogLevel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 日志级别映射
|
||||
LEVEL_MAP = {
|
||||
"DEBUG": LogLevel.DEBUG,
|
||||
"INFO": LogLevel.INFO,
|
||||
"WARNING": LogLevel.WARNING,
|
||||
"ERROR": LogLevel.ERROR,
|
||||
}
|
||||
|
||||
|
||||
class LogService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def log(
|
||||
self,
|
||||
message: str,
|
||||
level: str = "info",
|
||||
log_type: str = "system",
|
||||
user_id: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
) -> Log:
|
||||
"""记录日志"""
|
||||
log_entry = Log(
|
||||
level=level,
|
||||
type=log_type,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
source=source,
|
||||
details=json.dumps(details, ensure_ascii=False) if details else None,
|
||||
duration_ms=str(duration_ms) if duration_ms else None,
|
||||
)
|
||||
self.db.add(log_entry)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(log_entry)
|
||||
return log_entry
|
||||
|
||||
async def agent_log(
|
||||
self,
|
||||
message: str,
|
||||
user_id: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
) -> Log:
|
||||
"""记录智能体调用日志"""
|
||||
return await self.log(
|
||||
message=message,
|
||||
level="info",
|
||||
log_type="agent",
|
||||
user_id=user_id,
|
||||
source=source,
|
||||
details=details,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
async def system_log(
|
||||
self,
|
||||
message: str,
|
||||
level: str = "info",
|
||||
source: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
) -> Log:
|
||||
"""记录系统运行日志"""
|
||||
return await self.log(
|
||||
message=message,
|
||||
level=level,
|
||||
log_type="system",
|
||||
user_id=None,
|
||||
source=source,
|
||||
details=details,
|
||||
)
|
||||
|
||||
async def chat_log(
|
||||
self,
|
||||
message: str,
|
||||
user_id: str,
|
||||
details: Optional[dict] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
) -> Log:
|
||||
"""记录问答日志"""
|
||||
return await self.log(
|
||||
message=message,
|
||||
level="info",
|
||||
log_type="chat",
|
||||
user_id=user_id,
|
||||
source="chat",
|
||||
details=details,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
async def list_logs(
|
||||
self,
|
||||
log_type: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Log], int]:
|
||||
"""
|
||||
查询日志列表
|
||||
|
||||
Returns:
|
||||
(logs, total_count)
|
||||
"""
|
||||
conditions = []
|
||||
|
||||
if log_type:
|
||||
conditions.append(Log.type == log_type)
|
||||
if level:
|
||||
conditions.append(Log.level == level)
|
||||
if user_id:
|
||||
conditions.append(Log.user_id == user_id)
|
||||
if source:
|
||||
conditions.append(Log.source == source)
|
||||
|
||||
# 统计总数
|
||||
count_query = select(func.count(Log.id))
|
||||
if conditions:
|
||||
count_query = count_query.where(and_(*conditions))
|
||||
total_result = await self.db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 查询列表
|
||||
query = (
|
||||
select(Log)
|
||||
.where(and_(*conditions)) if conditions else select(Log)
|
||||
).order_by(desc(Log.created_at)).limit(limit).offset(offset)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
logs = list(result.scalars().all())
|
||||
|
||||
return logs, total
|
||||
|
||||
async def get_recent_logs(
|
||||
self,
|
||||
log_type: Optional[str] = None,
|
||||
hours: int = 24,
|
||||
limit: int = 100,
|
||||
) -> list[Log]:
|
||||
"""获取最近的日志"""
|
||||
since = datetime.utcnow() - timedelta(hours=hours)
|
||||
conditions = [Log.created_at >= since]
|
||||
|
||||
if log_type:
|
||||
conditions.append(Log.type == log_type)
|
||||
|
||||
query = (
|
||||
select(Log)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(Log.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_log_stats(self, hours: int = 24) -> dict:
|
||||
"""获取日志统计"""
|
||||
since = datetime.utcnow() - timedelta(hours=hours)
|
||||
|
||||
stats = {
|
||||
"total": 0,
|
||||
"by_type": {"agent": 0, "system": 0, "chat": 0},
|
||||
"by_level": {"debug": 0, "info": 0, "warning": 0, "error": 0},
|
||||
}
|
||||
|
||||
# 按类型统计
|
||||
for log_type in ["agent", "system", "chat"]:
|
||||
query = select(func.count(Log.id)).where(
|
||||
and_(Log.type == log_type, Log.created_at >= since)
|
||||
)
|
||||
result = await self.db.execute(query)
|
||||
count = result.scalar() or 0
|
||||
stats["by_type"][log_type] = count
|
||||
stats["total"] += count
|
||||
|
||||
# 按级别统计
|
||||
for level in ["debug", "info", "warning", "error"]:
|
||||
query = select(func.count(Log.id)).where(
|
||||
and_(Log.level == level, Log.created_at >= since)
|
||||
)
|
||||
result = await self.db.execute(query)
|
||||
stats["by_level"][level] = result.scalar() or 0
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# 全局日志记录函数,方便各处调用
|
||||
_global_db_session = None
|
||||
|
||||
|
||||
def set_log_session(db: AsyncSession):
|
||||
"""设置全局日志会话"""
|
||||
global _global_db_session
|
||||
_global_db_session = db
|
||||
|
||||
|
||||
def get_log_session() -> Optional[AsyncSession]:
|
||||
"""获取全局日志会话"""
|
||||
return _global_db_session
|
||||
|
||||
|
||||
async def log_agent_event(
|
||||
message: str,
|
||||
user_id: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
):
|
||||
"""记录智能体事件到数据库"""
|
||||
if _global_db_session:
|
||||
try:
|
||||
svc = LogService(_global_db_session)
|
||||
await svc.agent_log(message, user_id, source, details, duration_ms)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log agent event: {e}")
|
||||
|
||||
|
||||
async def log_system_event(
|
||||
message: str,
|
||||
level: str = "info",
|
||||
source: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
):
|
||||
"""记录系统事件到数据库"""
|
||||
if _global_db_session:
|
||||
try:
|
||||
svc = LogService(_global_db_session)
|
||||
await svc.system_log(message, level, source, details)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log system event: {e}")
|
||||
|
||||
|
||||
async def log_chat_event(
|
||||
message: str,
|
||||
user_id: str,
|
||||
details: Optional[dict] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
):
|
||||
"""记录聊天事件到数据库"""
|
||||
if _global_db_session:
|
||||
try:
|
||||
svc = LogService(_global_db_session)
|
||||
await svc.chat_log(message, user_id, details, duration_ms)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log chat event: {e}")
|
||||
61
frontend/src/api/log.ts
Normal file
61
frontend/src/api/log.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import api from './index'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
export interface Log {
|
||||
id: string
|
||||
level: 'debug' | 'info' | 'warning' | 'error'
|
||||
type: 'agent' | 'system' | 'chat'
|
||||
user_id: string | null
|
||||
message: string
|
||||
source: string | null
|
||||
details: string | null
|
||||
duration_ms: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LogStats {
|
||||
total: number
|
||||
by_type: {
|
||||
agent: number
|
||||
system: number
|
||||
chat: number
|
||||
}
|
||||
by_level: {
|
||||
debug: number
|
||||
info: number
|
||||
warning: number
|
||||
error: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface LogQueryResult {
|
||||
logs: Log[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export const logApi = {
|
||||
list: (params?: {
|
||||
log_type?: string
|
||||
level?: string
|
||||
source?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<AxiosResponse<LogQueryResult>> => {
|
||||
return api.get('/api/logs', { params })
|
||||
},
|
||||
|
||||
getStats: (hours?: number): Promise<AxiosResponse<LogStats>> => {
|
||||
return api.get('/api/logs/stats', { params: { hours } })
|
||||
},
|
||||
|
||||
getRecent: (params?: {
|
||||
log_type?: string
|
||||
hours?: number
|
||||
limit?: number
|
||||
}): Promise<AxiosResponse<Log[]>> => {
|
||||
return api.get('/api/logs/recent', { params })
|
||||
},
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { MessageCircle, BookOpen, Network, LayoutGrid, MessageSquare, LogOut, Cpu, Bot, Activity, CheckSquare, Settings, Star } from 'lucide-vue-next'
|
||||
import { MessageCircle, BookOpen, Network, LayoutGrid, MessageSquare, LogOut, Cpu, Bot, Activity, CheckSquare, Settings, Star, Terminal } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -17,6 +17,7 @@ const navItems = [
|
||||
{ name: '任务调度', path: '/todo', icon: CheckSquare },
|
||||
{ name: '信息交易所', path: '/forum', icon: MessageSquare },
|
||||
{ name: '运行状态', path: '/stats', icon: Activity },
|
||||
{ name: '运行日志', path: '/logs', icon: Terminal },
|
||||
{ name: '系统设置', path: '/settings', icon: Settings },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Terminal } from 'lucide-vue-next'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -69,6 +70,11 @@ const router = createRouter({
|
||||
name: 'settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'logs',
|
||||
component: () => import('@/views/LogView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
519
frontend/src/views/LogView.vue
Normal file
519
frontend/src/views/LogView.vue
Normal file
@@ -0,0 +1,519 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { logApi, type Log, type LogStats } from '@/api/log'
|
||||
import { Terminal, RefreshCw, Filter, Clock, Bot, MessageSquare, Settings, AlertCircle } from 'lucide-vue-next'
|
||||
|
||||
const logs = ref<Log[]>([])
|
||||
const stats = ref<LogStats | null>(null)
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const total = ref(0)
|
||||
|
||||
// Filters
|
||||
const selectedType = ref<string | null>(null)
|
||||
const selectedLevel = ref<string | null>(null)
|
||||
|
||||
// Auto-refresh
|
||||
const autoRefresh = ref(false)
|
||||
let refreshInterval: number | null = null
|
||||
|
||||
const logTypes = [
|
||||
{ value: null, label: '全部' },
|
||||
{ value: 'agent', label: '智能体', icon: Bot },
|
||||
{ value: 'system', label: '系统', icon: Settings },
|
||||
{ value: 'chat', label: '问答', icon: MessageSquare },
|
||||
]
|
||||
|
||||
const logLevels = [
|
||||
{ value: null, label: '全部' },
|
||||
{ value: 'debug', label: '调试' },
|
||||
{ value: 'info', label: '信息' },
|
||||
{ value: 'warning', label: '警告' },
|
||||
{ value: 'error', label: '错误' },
|
||||
]
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
debug: 'var(--text-dim)',
|
||||
info: 'var(--accent-cyan)',
|
||||
warning: 'var(--accent-amber)',
|
||||
error: 'var(--accent-red)',
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
agent: '智能体',
|
||||
system: '系统',
|
||||
chat: '问答',
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDuration(ms: string | null): string {
|
||||
if (!ms) return ''
|
||||
const msNum = parseInt(ms)
|
||||
if (msNum < 1000) return `${msNum}ms`
|
||||
return `${(msNum / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await logApi.list({
|
||||
log_type: selectedType.value || undefined,
|
||||
level: selectedLevel.value || undefined,
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
})
|
||||
logs.value = res.data.logs
|
||||
total.value = res.data.total
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch logs:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await logApi.getStats(24)
|
||||
stats.value = res.data
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch stats:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
await Promise.all([fetchLogs(), fetchStats()])
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefresh.value = !autoRefresh.value
|
||||
if (autoRefresh.value) {
|
||||
refreshInterval = window.setInterval(() => {
|
||||
fetchLogs()
|
||||
}, 5000)
|
||||
} else if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
function filterByType(type: string | null) {
|
||||
selectedType.value = type
|
||||
currentPage.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
function filterByLevel(level: string | null) {
|
||||
selectedLevel.value = level
|
||||
currentPage.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
fetchLogs()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value * pageSize.value < total.value) {
|
||||
currentPage.value++
|
||||
fetchLogs()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="log-view">
|
||||
<div class="view-header">
|
||||
<div class="header-title">
|
||||
<Terminal :size="16" />
|
||||
<span>运行日志</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-icon" :class="{ active: autoRefresh }" @click="toggleAutoRefresh" title="自动刷新">
|
||||
<RefreshCw :size="14" :class="{ spinning: autoRefresh }" />
|
||||
</button>
|
||||
<button class="btn-icon" @click="loadData" title="刷新">
|
||||
<RefreshCw :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<div v-if="stats" class="stats-overview">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">总计</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">智能体</div>
|
||||
<div class="stat-value cyan">{{ stats.by_type.agent }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">系统</div>
|
||||
<div class="stat-value purple">{{ stats.by_type.system }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">问答</div>
|
||||
<div class="stat-value amber">{{ stats.by_type.chat }}</div>
|
||||
</div>
|
||||
<div class="stat-card" v-if="stats.by_level.error > 0">
|
||||
<div class="stat-label">错误</div>
|
||||
<div class="stat-value red">{{ stats.by_level.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">类型:</span>
|
||||
<button
|
||||
v-for="t in logTypes"
|
||||
:key="t.value ?? 'all'"
|
||||
class="filter-btn"
|
||||
:class="{ active: selectedType === t.value }"
|
||||
@click="filterByType(t.value)"
|
||||
>
|
||||
<component :is="t.icon" :size="12" v-if="t.icon" />
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">级别:</span>
|
||||
<button
|
||||
v-for="l in logLevels"
|
||||
:key="l.value ?? 'all'"
|
||||
class="filter-btn"
|
||||
:class="{ active: selectedLevel === l.value }"
|
||||
@click="filterByLevel(l.value)"
|
||||
>
|
||||
{{ l.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log List -->
|
||||
<div class="log-list">
|
||||
<div v-if="loading && logs.length === 0" class="loading-state">
|
||||
<RefreshCw :size="20" class="spinning" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="logs.length === 0" class="empty-state">
|
||||
<Terminal :size="32" />
|
||||
<span>暂无日志</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="log-items">
|
||||
<div v-for="log in logs" :key="log.id" class="log-item">
|
||||
<div class="log-meta">
|
||||
<span class="log-type" :class="log.type">{{ typeLabels[log.type] }}</span>
|
||||
<span class="log-level" :style="{ color: levelColors[log.level] }">{{ log.level }}</span>
|
||||
<span v-if="log.duration_ms" class="log-duration">{{ formatDuration(log.duration_ms) }}</span>
|
||||
</div>
|
||||
<div class="log-message">{{ log.message }}</div>
|
||||
<div class="log-footer">
|
||||
<span v-if="log.source" class="log-source">{{ log.source }}</span>
|
||||
<span class="log-time">{{ formatTime(log.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="total > pageSize">
|
||||
<button class="page-btn" :disabled="currentPage === 1" @click="prevPage">上一页</button>
|
||||
<span class="page-info">{{ currentPage }} / {{ Math.ceil(total / pageSize) }}</span>
|
||||
<button class="page-btn" :disabled="currentPage * pageSize >= total" @click="nextPage">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.view-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover, .btn-icon.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.btn-icon .spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Stats Overview */
|
||||
.stats-overview {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-value.cyan { color: var(--accent-cyan); }
|
||||
.stat-value.purple { color: var(--accent-purple); }
|
||||
.stat-value.amber { color: var(--accent-amber); }
|
||||
.stat-value.red { color: var(--accent-red); }
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Log List */
|
||||
.log-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 60px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-items {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.log-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-item:hover {
|
||||
background: rgba(0, 245, 212, 0.02);
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.log-type {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.log-type.agent {
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.log-type.system {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.log-type.chat {
|
||||
background: rgba(249, 168, 37, 0.1);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.log-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.log-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user