生产管理系统前端 开发中心配置系统 所有页面
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
interface LoginLogFiltersProps {
|
||||
searchKeyword: string
|
||||
onSearchChange: (value: string) => void
|
||||
statusFilter: string
|
||||
onStatusFilterChange: (value: string) => void
|
||||
dateRange: string
|
||||
onDateRangeChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function LoginLogFilters({
|
||||
searchKeyword,
|
||||
onSearchChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
dateRange,
|
||||
onDateRangeChange
|
||||
}: LoginLogFiltersProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名、IP地址、位置..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="登录状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="success">成功</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={dateRange} onValueChange={onDateRangeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="时间范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部时间</SelectItem>
|
||||
<SelectItem value="today">今天</SelectItem>
|
||||
<SelectItem value="week">近7天</SelectItem>
|
||||
<SelectItem value="month">近30天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Shield } from 'lucide-react'
|
||||
|
||||
export function LoginLogInfo() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<Shield className="w-4 h-4 inline mr-2" />
|
||||
登录日志说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 记录所有用户的登录行为,包括成功和失败的登录尝试</li>
|
||||
<li>• 支持按用户名、IP地址、位置等多条件检索</li>
|
||||
<li>• 登录失败会记录失败原因,便于安全审计</li>
|
||||
<li>• 可用于识别异常登录行为和未授权访问尝试</li>
|
||||
<li>• 日志数据可导出用于长期归档和合规审计</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { LoginLog } from '@/types/monitor'
|
||||
|
||||
interface LoginLogStatsProps {
|
||||
logs: LoginLog[]
|
||||
}
|
||||
|
||||
export function LoginLogStats({ logs }: LoginLogStatsProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: '总登录次数',
|
||||
value: logs.length,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-100',
|
||||
},
|
||||
{
|
||||
label: '成功登录',
|
||||
value: logs.filter(l => l.status === 'success').length,
|
||||
color: 'text-green-600',
|
||||
bg: 'bg-green-100',
|
||||
},
|
||||
{
|
||||
label: '失败登录',
|
||||
value: logs.filter(l => l.status === 'failed').length,
|
||||
color: 'text-red-600',
|
||||
bg: 'bg-red-100',
|
||||
},
|
||||
{
|
||||
label: '今日登录',
|
||||
value: logs.filter(l => {
|
||||
const logDate = new Date(l.loginTime)
|
||||
const today = new Date()
|
||||
return logDate.toDateString() === today.toDateString()
|
||||
}).length,
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-100',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
<div className={`mt-2 ${stat.color}`}>{stat.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { LoginLog } from '@/types/monitor'
|
||||
import { Shield, Monitor, MapPin } from 'lucide-react'
|
||||
|
||||
interface LoginLogTableProps {
|
||||
logs: LoginLog[]
|
||||
}
|
||||
|
||||
export function LoginLogTable({ logs }: LoginLogTableProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>登录时间</TableHead>
|
||||
<TableHead>IP地址</TableHead>
|
||||
<TableHead>设备/浏览器</TableHead>
|
||||
<TableHead>位置</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
暂无登录日志
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-gray-400" />
|
||||
{log.username}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(log.loginTime).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{log.ipAddress}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-gray-400" />
|
||||
<div className="text-sm">
|
||||
<div>{log.device}</div>
|
||||
{log.browser && (
|
||||
<div className="text-xs text-muted-foreground">{log.browser}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.location && (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<MapPin className="w-3 h-3 text-gray-400" />
|
||||
{log.location}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.status === 'success' ? (
|
||||
<Badge className="bg-green-100 text-green-700">成功</Badge>
|
||||
) : (
|
||||
<div>
|
||||
<Badge className="bg-red-100 text-red-700">失败</Badge>
|
||||
{log.failReason && (
|
||||
<p className="text-xs text-red-600 mt-1">{log.failReason}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { LoginLogStats } from './LoginLogStats'
|
||||
export { LoginLogFilters } from './LoginLogFilters'
|
||||
export { LoginLogTable } from './LoginLogTable'
|
||||
export { LoginLogInfo } from './LoginLogInfo'
|
||||
202
crop-x/src/app/(app)/central-config/monitor/login-log/page.tsx
Normal file
202
crop-x/src/app/(app)/central-config/monitor/login-log/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoginLog } from '@/types/monitor'
|
||||
import { Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Import modular components
|
||||
import {
|
||||
LoginLogStats,
|
||||
LoginLogFilters,
|
||||
LoginLogTable,
|
||||
LoginLogInfo
|
||||
} from './components'
|
||||
|
||||
export default function LoginLogPage() {
|
||||
const [logs, setLogs] = useState<LoginLog[]>([])
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [dateRange, setDateRange] = useState<string>('all')
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [])
|
||||
|
||||
const loadLogs = () => {
|
||||
// 强制重新加载mock数据以解决显示问题
|
||||
localStorage.removeItem('smart_agriculture_login_logs')
|
||||
|
||||
const mockLogs: LoginLog[] = [
|
||||
{
|
||||
id: 'log-1',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
loginTime: '2024-10-21T09:30:00',
|
||||
ipAddress: '192.168.1.100',
|
||||
device: 'Windows 11',
|
||||
browser: 'Chrome 118',
|
||||
os: 'Windows',
|
||||
location: '北京市海淀区',
|
||||
status: 'success',
|
||||
sessionId: 'sess-001',
|
||||
},
|
||||
{
|
||||
id: 'log-2',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
loginTime: '2024-10-21T10:15:00',
|
||||
ipAddress: '192.168.1.101',
|
||||
device: 'macOS 14',
|
||||
browser: 'Safari 17',
|
||||
os: 'macOS',
|
||||
location: '上海市浦东新区',
|
||||
status: 'success',
|
||||
sessionId: 'sess-002',
|
||||
},
|
||||
{
|
||||
id: 'log-3',
|
||||
userId: 'user-3',
|
||||
username: 'lisi',
|
||||
loginTime: '2024-10-21T11:20:00',
|
||||
ipAddress: '192.168.1.102',
|
||||
device: 'Android 13',
|
||||
browser: 'Chrome Mobile 118',
|
||||
os: 'Android',
|
||||
location: '广州市天河区',
|
||||
status: 'failed',
|
||||
failReason: '密码错误',
|
||||
},
|
||||
{
|
||||
id: 'log-4',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
loginTime: '2024-10-21T14:45:00',
|
||||
ipAddress: '192.168.1.100',
|
||||
device: 'Windows 11',
|
||||
browser: 'Chrome 118',
|
||||
os: 'Windows',
|
||||
location: '北京市海淀区',
|
||||
status: 'success',
|
||||
sessionId: 'sess-003',
|
||||
},
|
||||
{
|
||||
id: 'log-5',
|
||||
userId: 'user-4',
|
||||
username: 'wangwu',
|
||||
loginTime: '2024-10-21T15:30:00',
|
||||
ipAddress: '192.168.1.103',
|
||||
device: 'iOS 17',
|
||||
browser: 'Safari 17',
|
||||
os: 'iOS',
|
||||
location: '深圳市南山区',
|
||||
status: 'failed',
|
||||
failReason: '账号被锁定',
|
||||
},
|
||||
{
|
||||
id: 'log-6',
|
||||
userId: 'user-5',
|
||||
username: 'zhaoliu',
|
||||
loginTime: '2024-10-21T16:20:00',
|
||||
ipAddress: '192.168.1.104',
|
||||
device: 'Windows 10',
|
||||
browser: 'Edge 118',
|
||||
os: 'Windows',
|
||||
location: '杭州市西湖区',
|
||||
status: 'success',
|
||||
sessionId: 'sess-004',
|
||||
},
|
||||
{
|
||||
id: 'log-7',
|
||||
userId: 'user-6',
|
||||
username: 'chenqi',
|
||||
loginTime: '2024-10-21T17:10:00',
|
||||
ipAddress: '192.168.1.105',
|
||||
device: 'Ubuntu 22.04',
|
||||
browser: 'Firefox 119',
|
||||
os: 'Linux',
|
||||
location: '成都市武侯区',
|
||||
status: 'success',
|
||||
sessionId: 'sess-005',
|
||||
},
|
||||
]
|
||||
localStorage.setItem('smart_agriculture_login_logs', JSON.stringify(mockLogs))
|
||||
setLogs(mockLogs)
|
||||
}
|
||||
|
||||
const filteredLogs = logs.filter(log => {
|
||||
const matchKeyword = !searchKeyword ||
|
||||
log.username.includes(searchKeyword) ||
|
||||
log.ipAddress.includes(searchKeyword) ||
|
||||
(log.location && log.location.includes(searchKeyword))
|
||||
|
||||
const matchStatus = statusFilter === 'all' || log.status === statusFilter
|
||||
|
||||
let matchDate = true
|
||||
if (dateRange !== 'all') {
|
||||
const logDate = new Date(log.loginTime)
|
||||
const now = new Date()
|
||||
const diffDays = Math.floor((now.getTime() - logDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
switch (dateRange) {
|
||||
case 'today':
|
||||
matchDate = diffDays === 0
|
||||
break
|
||||
case 'week':
|
||||
matchDate = diffDays <= 7
|
||||
break
|
||||
case 'month':
|
||||
matchDate = diffDays <= 30
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return matchKeyword && matchStatus && matchDate
|
||||
})
|
||||
|
||||
const handleExport = () => {
|
||||
const dataStr = JSON.stringify(filteredLogs, null, 2)
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(dataBlob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `login_logs_${new Date().getTime()}.json`
|
||||
link.click()
|
||||
toast.success('导出成功')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">登录日志</h2>
|
||||
<p className="text-muted-foreground">全面记录所有用户的登录行为</p>
|
||||
</div>
|
||||
<Button onClick={handleExport}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出日志
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<LoginLogStats logs={logs} />
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<LoginLogFilters
|
||||
searchKeyword={searchKeyword}
|
||||
onSearchChange={setSearchKeyword}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
dateRange={dateRange}
|
||||
onDateRangeChange={setDateRange}
|
||||
/>
|
||||
|
||||
{/* 日志列表 */}
|
||||
<LoginLogTable logs={filteredLogs} />
|
||||
|
||||
{/* 使用说明 */}
|
||||
<LoginLogInfo />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { NetworkLog } from '@/types/monitor'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
interface NetworkLogDetailDialogProps {
|
||||
log: NetworkLog | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function NetworkLogDetailDialog({
|
||||
log,
|
||||
isOpen,
|
||||
onClose,
|
||||
isLoading = false
|
||||
}: NetworkLogDetailDialogProps) {
|
||||
const getMethodBadge = (method: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
GET: 'bg-blue-100 text-blue-700',
|
||||
POST: 'bg-green-100 text-green-700',
|
||||
PUT: 'bg-yellow-100 text-yellow-700',
|
||||
DELETE: 'bg-red-100 text-red-700',
|
||||
PATCH: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
return colors[method] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: number) => {
|
||||
if (status >= 200 && status < 300) {
|
||||
return 'bg-green-100 text-green-700'
|
||||
} else if (status >= 400 && status < 500) {
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
} else if (status >= 500) {
|
||||
return 'bg-red-100 text-red-700'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-green-600" />
|
||||
网络请求详情
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="bg-gray-200 h-4 w-24 rounded mb-2"></div>
|
||||
<div className="bg-gray-200 h-6 w-40 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : log ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求时间</p>
|
||||
<p className="mt-1">{new Date(log.timestamp).toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求方法</p>
|
||||
<p className="mt-1">
|
||||
<Badge className={getMethodBadge(log.method)}>
|
||||
{log.method}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-sm text-muted-foreground">请求URL</p>
|
||||
<p className="mt-1">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded block break-all">
|
||||
{log.url}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">客户端IP</p>
|
||||
<p className="mt-1">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{log.clientIp}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">用户</p>
|
||||
<p className="mt-1">{log.username || '未登录'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">响应状态</p>
|
||||
<p className="mt-1">
|
||||
<Badge className={getStatusBadge(log.responseStatus)}>
|
||||
{log.responseStatus}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">响应时间</p>
|
||||
<p className="mt-1">{log.responseTime}ms</p>
|
||||
</div>
|
||||
{log.responseSize && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">响应大小</p>
|
||||
<p className="mt-1">{formatBytes(log.responseSize)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.requestParams && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求参数</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{log.requestParams}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.requestHeaders && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求头</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(log.requestHeaders), null, 2)
|
||||
} catch {
|
||||
return log.requestHeaders
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.requestBody && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求体</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{log.requestBody}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.responseBody && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">响应体</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto max-h-40 overflow-y-auto">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(log.responseBody), null, 2)
|
||||
} catch {
|
||||
return log.responseBody
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.userAgent && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">User Agent</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground break-all">
|
||||
{log.userAgent}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
interface NetworkLogFiltersProps {
|
||||
searchKeyword: string
|
||||
onSearchChange: (value: string) => void
|
||||
methodFilter: string
|
||||
onMethodFilterChange: (value: string) => void
|
||||
statusFilter: string
|
||||
onStatusFilterChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function NetworkLogFilters({
|
||||
searchKeyword,
|
||||
onSearchChange,
|
||||
methodFilter,
|
||||
onMethodFilterChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange
|
||||
}: NetworkLogFiltersProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索URL、用户名..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={methodFilter} onValueChange={onMethodFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请求方法" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部方法</SelectItem>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="响应状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="2xx">2xx 成功</SelectItem>
|
||||
<SelectItem value="4xx">4xx 客户端错误</SelectItem>
|
||||
<SelectItem value="5xx">5xx 服务器错误</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
export function NetworkLogInfo() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<Globe className="w-4 h-4 inline mr-2" />
|
||||
网络日志说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 记录所有HTTP请求的详细信息,包括请求地址、参数、响应状态等</li>
|
||||
<li>• 响应时间超过1秒的请求需要关注,可能存在性能问题</li>
|
||||
<li>• 4xx状态码通常表示客户端错误,5xx表示服务器错误</li>
|
||||
<li>• 可用于接口调试、性能分析和异常排查</li>
|
||||
<li>• 建议定期清理历史日志,避免占用过多存储空间</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { NetworkLog } from '@/types/monitor'
|
||||
import { ApiResponse, PaginatedResponse, PaginationParams } from '@/types'
|
||||
|
||||
export interface NetworkLogFilters {
|
||||
searchKeyword?: string
|
||||
method?: string
|
||||
status?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
minResponseTime?: number
|
||||
maxResponseTime?: number
|
||||
}
|
||||
|
||||
export interface NetworkLogListParams extends PaginationParams {
|
||||
filters?: NetworkLogFilters
|
||||
}
|
||||
|
||||
export class NetworkLogService {
|
||||
private static baseUrl = '/api/monitor/network-logs'
|
||||
|
||||
/**
|
||||
* 获取网络日志列表
|
||||
*/
|
||||
static async getNetworkLogs(params: NetworkLogListParams = {}): Promise<PaginatedResponse<NetworkLog>> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
// 添加分页参数
|
||||
if (params.page) queryParams.append('page', params.page.toString())
|
||||
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
|
||||
|
||||
// 添加筛选参数
|
||||
if (params.filters) {
|
||||
if (params.filters.searchKeyword) queryParams.append('searchKeyword', params.filters.searchKeyword)
|
||||
if (params.filters.method && params.filters.method !== 'all') queryParams.append('method', params.filters.method)
|
||||
if (params.filters.status && params.filters.status !== 'all') queryParams.append('status', params.filters.status)
|
||||
if (params.filters.startDate) queryParams.append('startDate', params.filters.startDate)
|
||||
if (params.filters.endDate) queryParams.append('endDate', params.filters.endDate)
|
||||
if (params.filters.minResponseTime) queryParams.append('minResponseTime', params.filters.minResponseTime.toString())
|
||||
if (params.filters.maxResponseTime) queryParams.append('maxResponseTime', params.filters.maxResponseTime.toString())
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch network logs:', error)
|
||||
// 降级处理:返回mock数据
|
||||
return this.getMockData(params)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网络日志详情
|
||||
*/
|
||||
static async getNetworkLogDetail(id: string): Promise<ApiResponse<NetworkLog>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/${id}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch network log detail:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出网络日志
|
||||
*/
|
||||
static async exportNetworkLogs(filters?: NetworkLogFilters): Promise<Blob> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.searchKeyword) queryParams.append('searchKeyword', filters.searchKeyword)
|
||||
if (filters.method && filters.method !== 'all') queryParams.append('method', filters.method)
|
||||
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status)
|
||||
if (filters.startDate) queryParams.append('startDate', filters.startDate)
|
||||
if (filters.endDate) queryParams.append('endDate', filters.endDate)
|
||||
if (filters.minResponseTime) queryParams.append('minResponseTime', filters.minResponseTime.toString())
|
||||
if (filters.maxResponseTime) queryParams.append('maxResponseTime', filters.maxResponseTime.toString())
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/export?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
} catch (error) {
|
||||
console.error('Failed to export network logs:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网络日志统计信息
|
||||
*/
|
||||
static async getNetworkLogStats(filters?: NetworkLogFilters): Promise<ApiResponse<{
|
||||
total: number
|
||||
success: number
|
||||
clientError: number
|
||||
serverError: number
|
||||
averageResponseTime: number
|
||||
totalResponseSize: number
|
||||
methodStats: Array<{ method: string, count: number }>
|
||||
statusStats: Array<{ status: number, count: number }>
|
||||
topSlowRequests: Array<{ url: string, responseTime: number, count: number }>
|
||||
}>> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.method && filters.method !== 'all') queryParams.append('method', filters.method)
|
||||
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status)
|
||||
if (filters.startDate) queryParams.append('startDate', filters.startDate)
|
||||
if (filters.endDate) queryParams.append('endDate', filters.endDate)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/stats?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch network log stats:', error)
|
||||
// 降级处理:返回mock统计数据
|
||||
return this.getMockStats()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mock数据 - 用于降级处理
|
||||
*/
|
||||
private static getMockData(params: NetworkLogListParams): PaginatedResponse<NetworkLog> {
|
||||
const mockLogs: NetworkLog[] = [
|
||||
{
|
||||
id: 'net-1',
|
||||
timestamp: '2024-10-21T09:35:00',
|
||||
method: 'POST',
|
||||
url: '/api/users',
|
||||
requestParams: 'username=zhangsan&name=张三',
|
||||
responseStatus: 200,
|
||||
responseTime: 150,
|
||||
responseSize: 256,
|
||||
clientIp: '192.168.1.100',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/118.0.0.0',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'net-2',
|
||||
timestamp: '2024-10-21T10:20:00',
|
||||
method: 'GET',
|
||||
url: '/api/machinery/list',
|
||||
requestParams: 'page=1&size=10',
|
||||
responseStatus: 200,
|
||||
responseTime: 89,
|
||||
responseSize: 4096,
|
||||
clientIp: '192.168.1.101',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17.0',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
},
|
||||
{
|
||||
id: 'net-3',
|
||||
timestamp: '2024-10-21T11:25:00',
|
||||
method: 'DELETE',
|
||||
url: '/api/roles/456',
|
||||
responseStatus: 400,
|
||||
responseTime: 120,
|
||||
responseSize: 128,
|
||||
clientIp: '192.168.1.102',
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/118.0.0.0',
|
||||
userId: 'user-3',
|
||||
username: 'lisi',
|
||||
},
|
||||
{
|
||||
id: 'net-4',
|
||||
timestamp: '2024-10-21T14:50:00',
|
||||
method: 'PUT',
|
||||
url: '/api/system/settings',
|
||||
requestParams: 'sessionTimeout=30',
|
||||
responseStatus: 200,
|
||||
responseTime: 95,
|
||||
responseSize: 512,
|
||||
clientIp: '192.168.1.100',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/118.0.0.0',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'net-5',
|
||||
timestamp: '2024-10-21T15:35:00',
|
||||
method: 'POST',
|
||||
url: '/api/tasks',
|
||||
responseStatus: 201,
|
||||
responseTime: 180,
|
||||
responseSize: 1024,
|
||||
clientIp: '192.168.1.101',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17.0',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
},
|
||||
{
|
||||
id: 'net-6',
|
||||
timestamp: '2024-10-21T16:15:00',
|
||||
method: 'GET',
|
||||
url: '/api/users/export',
|
||||
requestParams: 'format=excel',
|
||||
responseStatus: 200,
|
||||
responseTime: 1250,
|
||||
responseSize: 102400,
|
||||
clientIp: '192.168.1.100',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/118.0.0.0',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
},
|
||||
]
|
||||
|
||||
// 应用筛选器
|
||||
let filteredLogs = mockLogs.filter(log => {
|
||||
if (params.filters?.searchKeyword) {
|
||||
const keyword = params.filters.searchKeyword.toLowerCase()
|
||||
if (!log.url.toLowerCase().includes(keyword) &&
|
||||
!(log.username && log.username.toLowerCase().includes(keyword))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (params.filters?.method && params.filters.method !== 'all') {
|
||||
if (log.method !== params.filters.method) return false
|
||||
}
|
||||
|
||||
if (params.filters?.status && params.filters.status !== 'all') {
|
||||
const status = log.responseStatus
|
||||
switch (params.filters.status) {
|
||||
case '2xx':
|
||||
if (status < 200 || status >= 300) return false
|
||||
break
|
||||
case '4xx':
|
||||
if (status < 400 || status >= 500) return false
|
||||
break
|
||||
case '5xx':
|
||||
if (status < 500) return false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (params.filters?.minResponseTime && log.responseTime < params.filters.minResponseTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (params.filters?.maxResponseTime && log.responseTime > params.filters.maxResponseTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 应用分页
|
||||
const page = params.page || 1
|
||||
const pageSize = params.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const endIndex = startIndex + pageSize
|
||||
const paginatedLogs = filteredLogs.slice(startIndex, endIndex)
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
success: true,
|
||||
data: paginatedLogs,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total: filteredLogs.length,
|
||||
totalPages: Math.ceil(filteredLogs.length / pageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static getMockStats(): ApiResponse<{
|
||||
total: number
|
||||
success: number
|
||||
clientError: number
|
||||
serverError: number
|
||||
averageResponseTime: number
|
||||
totalResponseSize: number
|
||||
methodStats: Array<{ method: string, count: number }>
|
||||
statusStats: Array<{ status: number, count: number }>
|
||||
topSlowRequests: Array<{ url: string, responseTime: number, count: number }>
|
||||
}> {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
success: true,
|
||||
data: {
|
||||
total: 6,
|
||||
success: 4,
|
||||
clientError: 1,
|
||||
serverError: 1,
|
||||
averageResponseTime: 314,
|
||||
totalResponseSize: 108416,
|
||||
methodStats: [
|
||||
{ method: 'GET', count: 2 },
|
||||
{ method: 'POST', count: 2 },
|
||||
{ method: 'PUT', count: 1 },
|
||||
{ method: 'DELETE', count: 1 }
|
||||
],
|
||||
statusStats: [
|
||||
{ status: 200, count: 3 },
|
||||
{ status: 201, count: 1 },
|
||||
{ status: 400, count: 1 },
|
||||
{ status: 500, count: 1 }
|
||||
],
|
||||
topSlowRequests: [
|
||||
{ url: '/api/users/export', responseTime: 1250, count: 1 },
|
||||
{ url: '/api/tasks', responseTime: 180, count: 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { NetworkLog } from '@/types/monitor'
|
||||
|
||||
interface NetworkLogStatsProps {
|
||||
logs: NetworkLog[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function NetworkLogStats({ logs, isLoading = false }: NetworkLogStatsProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: '总请求数',
|
||||
value: logs.length,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '成功请求',
|
||||
value: logs.filter(l => l.responseStatus >= 200 && l.responseStatus < 300).length,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '失败请求',
|
||||
value: logs.filter(l => l.responseStatus >= 400).length,
|
||||
color: 'text-red-600',
|
||||
},
|
||||
{
|
||||
label: '平均响应时间',
|
||||
value: logs.length > 0
|
||||
? Math.round(logs.reduce((sum, l) => sum + l.responseTime, 0) / logs.length) + 'ms'
|
||||
: '0ms',
|
||||
color: 'text-purple-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
<div className={`mt-2 text-2xl font-semibold ${stat.color}`}>
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse bg-gray-200 h-8 w-16 rounded"></div>
|
||||
) : (
|
||||
stat.value
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { NetworkLog } from '@/types/monitor'
|
||||
import { Eye, Clock } from 'lucide-react'
|
||||
|
||||
interface NetworkLogTableProps {
|
||||
logs: NetworkLog[]
|
||||
isLoading?: boolean
|
||||
onViewDetail: (log: NetworkLog) => void
|
||||
}
|
||||
|
||||
export function NetworkLogTable({ logs, isLoading = false, onViewDetail }: NetworkLogTableProps) {
|
||||
const getMethodBadge = (method: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
GET: 'bg-blue-100 text-blue-700',
|
||||
POST: 'bg-green-100 text-green-700',
|
||||
PUT: 'bg-yellow-100 text-yellow-700',
|
||||
DELETE: 'bg-red-100 text-red-700',
|
||||
PATCH: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
return colors[method] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: number) => {
|
||||
if (status >= 200 && status < 300) {
|
||||
return 'bg-green-100 text-green-700'
|
||||
} else if (status >= 400 && status < 500) {
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
} else if (status >= 500) {
|
||||
return 'bg-red-100 text-red-700'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-8 space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="flex space-x-4">
|
||||
<div className="bg-gray-200 h-4 w-20 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-32 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-20 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-20 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-8 w-8 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>方法</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>耗时</TableHead>
|
||||
<TableHead>大小</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||
暂无网络日志
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(log.timestamp).toLocaleTimeString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getMethodBadge(log.method)}>
|
||||
{log.method}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-sm" title={log.url}>
|
||||
{log.url}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{log.username || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getStatusBadge(log.responseStatus)}>
|
||||
{log.responseStatus}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-gray-400" />
|
||||
{log.responseTime}ms
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{log.responseSize ? formatBytes(log.responseSize) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewDetail(log)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { NetworkLogService } from './NetworkLogService'
|
||||
export { NetworkLogStats } from './NetworkLogStats'
|
||||
export { NetworkLogFilters } from './NetworkLogFilters'
|
||||
export { NetworkLogTable } from './NetworkLogTable'
|
||||
export { NetworkLogDetailDialog } from './NetworkLogDetailDialog'
|
||||
export { NetworkLogInfo } from './NetworkLogInfo'
|
||||
|
||||
export type { NetworkLogFilters as NetworkLogFiltersType, NetworkLogListParams } from './NetworkLogService'
|
||||
139
crop-x/src/app/(app)/central-config/monitor/network-log/page.tsx
Normal file
139
crop-x/src/app/(app)/central-config/monitor/network-log/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { NetworkLog } from '@/types/monitor'
|
||||
import { Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Import modular components
|
||||
import {
|
||||
NetworkLogService,
|
||||
NetworkLogStats,
|
||||
NetworkLogFilters,
|
||||
NetworkLogTable,
|
||||
NetworkLogDetailDialog,
|
||||
NetworkLogInfo
|
||||
} from './components'
|
||||
|
||||
export default function NetworkLogPage() {
|
||||
const [logs, setLogs] = useState<NetworkLog[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [methodFilter, setMethodFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [showDetailDialog, setShowDetailDialog] = useState(false)
|
||||
const [selectedLog, setSelectedLog] = useState<NetworkLog | null>(null)
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [searchKeyword, methodFilter, statusFilter])
|
||||
|
||||
const loadLogs = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await NetworkLogService.getNetworkLogs({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: {
|
||||
searchKeyword,
|
||||
method: methodFilter,
|
||||
status: statusFilter
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
setLogs(response.data)
|
||||
} else {
|
||||
throw new Error(response.message || '加载网络日志失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load network logs:', error)
|
||||
toast.error('加载网络日志失败,请稍后重试')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = async (log: NetworkLog) => {
|
||||
setSelectedLog(log)
|
||||
setShowDetailDialog(true)
|
||||
|
||||
setIsDetailLoading(true)
|
||||
try {
|
||||
const response = await NetworkLogService.getNetworkLogDetail(log.id)
|
||||
if (response.success) {
|
||||
setSelectedLog(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch log detail:', error)
|
||||
} finally {
|
||||
setIsDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await NetworkLogService.exportNetworkLogs({
|
||||
searchKeyword,
|
||||
method: methodFilter,
|
||||
status: statusFilter
|
||||
})
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `network_logs_${new Date().getTime()}.json`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success('导出成功')
|
||||
} catch (error) {
|
||||
console.error('Failed to export logs:', error)
|
||||
toast.error('导出失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">网络日志</h2>
|
||||
<p className="text-muted-foreground">记录系统接收与发送的所有网络请求信息</p>
|
||||
</div>
|
||||
<Button onClick={handleExport} disabled={isLoading || logs.length === 0}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出日志
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<NetworkLogStats logs={logs} isLoading={isLoading} />
|
||||
|
||||
<NetworkLogFilters
|
||||
searchKeyword={searchKeyword}
|
||||
onSearchChange={setSearchKeyword}
|
||||
methodFilter={methodFilter}
|
||||
onMethodFilterChange={setMethodFilter}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
/>
|
||||
|
||||
<NetworkLogTable
|
||||
logs={logs}
|
||||
isLoading={isLoading}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
|
||||
<NetworkLogDetailDialog
|
||||
log={selectedLog}
|
||||
isOpen={showDetailDialog}
|
||||
onClose={() => setShowDetailDialog(false)}
|
||||
isLoading={isDetailLoading}
|
||||
/>
|
||||
|
||||
<NetworkLogInfo />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { OperationLog } from '@/types/monitor'
|
||||
import { FileText } from 'lucide-react'
|
||||
|
||||
interface OperationLogDetailDialogProps {
|
||||
log: OperationLog | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function OperationLogDetailDialog({
|
||||
log,
|
||||
isOpen,
|
||||
onClose,
|
||||
isLoading = false
|
||||
}: OperationLogDetailDialogProps) {
|
||||
const getModuleLabel = (module: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
user: '用户管理',
|
||||
role: '角色管理',
|
||||
permission: '权限管理',
|
||||
machinery: '农机管理',
|
||||
driver: '驾驶员管理',
|
||||
task: '任务管理',
|
||||
system: '系统配置',
|
||||
other: '其他',
|
||||
}
|
||||
return labels[module] || module
|
||||
}
|
||||
|
||||
const getActionLabel = (action: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
create: '新增',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
view: '查看',
|
||||
export: '导出',
|
||||
import: '导入',
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
}
|
||||
return labels[action] || action
|
||||
}
|
||||
|
||||
const getActionBadge = (action: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
create: 'bg-green-100 text-green-700',
|
||||
update: 'bg-blue-100 text-blue-700',
|
||||
delete: 'bg-red-100 text-red-700',
|
||||
view: 'bg-gray-100 text-gray-700',
|
||||
export: 'bg-purple-100 text-purple-700',
|
||||
import: 'bg-yellow-100 text-yellow-700',
|
||||
}
|
||||
return colors[action] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-green-600" />
|
||||
操作日志详情
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="bg-gray-200 h-4 w-24 rounded mb-2"></div>
|
||||
<div className="bg-gray-200 h-6 w-40 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : log ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作人</p>
|
||||
<p className="mt-1 font-medium">{log.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作时间</p>
|
||||
<p className="mt-1">{new Date(log.operationTime).toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作模块</p>
|
||||
<p className="mt-1">
|
||||
<Badge variant="outline">{getModuleLabel(log.module)}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作类型</p>
|
||||
<p className="mt-1">
|
||||
<Badge className={getActionBadge(log.action)}>
|
||||
{getActionLabel(log.action)}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">IP地址</p>
|
||||
<p className="mt-1">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{log.ipAddress}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">耗时</p>
|
||||
<p className="mt-1">{log.duration ? `${log.duration}ms` : '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作描述</p>
|
||||
<p className="mt-1">{log.description}</p>
|
||||
</div>
|
||||
|
||||
{log.requestUrl && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求URL</p>
|
||||
<p className="mt-1">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{log.requestMethod} {log.requestUrl}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.requestParams && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求参数</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(log.requestParams), null, 2)
|
||||
} catch {
|
||||
return log.requestParams
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.responseData && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">响应数据</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(log.responseData), null, 2)
|
||||
} catch {
|
||||
return log.responseData
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.errorMessage && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">错误信息</p>
|
||||
<p className="mt-1 text-red-600 bg-red-50 p-3 rounded">{log.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
interface OperationLogFiltersProps {
|
||||
searchKeyword: string
|
||||
onSearchChange: (value: string) => void
|
||||
moduleFilter: string
|
||||
onModuleFilterChange: (value: string) => void
|
||||
actionFilter: string
|
||||
onActionFilterChange: (value: string) => void
|
||||
statusFilter: string
|
||||
onStatusFilterChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function OperationLogFilters({
|
||||
searchKeyword,
|
||||
onSearchChange,
|
||||
moduleFilter,
|
||||
onModuleFilterChange,
|
||||
actionFilter,
|
||||
onActionFilterChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange
|
||||
}: OperationLogFiltersProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名、操作描述..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={moduleFilter} onValueChange={onModuleFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="操作模块" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部模块</SelectItem>
|
||||
<SelectItem value="user">用户管理</SelectItem>
|
||||
<SelectItem value="role">角色管理</SelectItem>
|
||||
<SelectItem value="permission">权限管理</SelectItem>
|
||||
<SelectItem value="machinery">农机管理</SelectItem>
|
||||
<SelectItem value="driver">驾驶员管理</SelectItem>
|
||||
<SelectItem value="task">任务管理</SelectItem>
|
||||
<SelectItem value="system">系统配置</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={actionFilter} onValueChange={onActionFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="操作类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="create">新增</SelectItem>
|
||||
<SelectItem value="update">修改</SelectItem>
|
||||
<SelectItem value="delete">删除</SelectItem>
|
||||
<SelectItem value="view">查看</SelectItem>
|
||||
<SelectItem value="export">导出</SelectItem>
|
||||
<SelectItem value="import">导入</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="操作状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="success">成功</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Activity } from 'lucide-react'
|
||||
|
||||
export function OperationLogInfo() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<Activity className="w-4 h-4 inline mr-2" />
|
||||
操作日志说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 记录用户在系统中的所有关键操作,包括数据修改、流程触发、配置变更等</li>
|
||||
<li>• 详细记录操作人、时间、模块、动作及请求详情</li>
|
||||
<li>• 支持按多个维度进行复杂条件搜索和过滤</li>
|
||||
<li>• 可查看原始请求信息,满足运维审计需求</li>
|
||||
<li>• 失败操作会记录错误信息,便于事故追溯</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { OperationLog } from '@/types/monitor'
|
||||
import { ApiResponse, PaginatedResponse, PaginationParams } from '@/types'
|
||||
|
||||
export interface OperationLogFilters {
|
||||
searchKeyword?: string
|
||||
module?: string
|
||||
action?: string
|
||||
status?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export interface OperationLogListParams extends PaginationParams {
|
||||
filters?: OperationLogFilters
|
||||
}
|
||||
|
||||
export class OperationLogService {
|
||||
private static baseUrl = '/api/monitor/operation-logs'
|
||||
|
||||
/**
|
||||
* 获取操作日志列表
|
||||
*/
|
||||
static async getOperationLogs(params: OperationLogListParams = {}): Promise<PaginatedResponse<OperationLog>> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
// 添加分页参数
|
||||
if (params.page) queryParams.append('page', params.page.toString())
|
||||
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
|
||||
|
||||
// 添加筛选参数
|
||||
if (params.filters) {
|
||||
if (params.filters.searchKeyword) queryParams.append('searchKeyword', params.filters.searchKeyword)
|
||||
if (params.filters.module && params.filters.module !== 'all') queryParams.append('module', params.filters.module)
|
||||
if (params.filters.action && params.filters.action !== 'all') queryParams.append('action', params.filters.action)
|
||||
if (params.filters.status && params.filters.status !== 'all') queryParams.append('status', params.filters.status)
|
||||
if (params.filters.startDate) queryParams.append('startDate', params.filters.startDate)
|
||||
if (params.filters.endDate) queryParams.append('endDate', params.filters.endDate)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch operation logs:', error)
|
||||
// 降级处理:返回mock数据
|
||||
return this.getMockData(params)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作日志详情
|
||||
*/
|
||||
static async getOperationLogDetail(id: string): Promise<ApiResponse<OperationLog>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/${id}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch operation log detail:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出操作日志
|
||||
*/
|
||||
static async exportOperationLogs(filters?: OperationLogFilters): Promise<Blob> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.searchKeyword) queryParams.append('searchKeyword', filters.searchKeyword)
|
||||
if (filters.module && filters.module !== 'all') queryParams.append('module', filters.module)
|
||||
if (filters.action && filters.action !== 'all') queryParams.append('action', filters.action)
|
||||
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status)
|
||||
if (filters.startDate) queryParams.append('startDate', filters.startDate)
|
||||
if (filters.endDate) queryParams.append('endDate', filters.endDate)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/export?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
} catch (error) {
|
||||
console.error('Failed to export operation logs:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作日志统计信息
|
||||
*/
|
||||
static async getOperationLogStats(filters?: OperationLogFilters): Promise<ApiResponse<{
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
averageDuration: number
|
||||
moduleStats: Array<{ module: string, count: number }>
|
||||
actionStats: Array<{ action: string, count: number }>
|
||||
}>> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.module && filters.module !== 'all') queryParams.append('module', filters.module)
|
||||
if (filters.action && filters.action !== 'all') queryParams.append('action', filters.action)
|
||||
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status)
|
||||
if (filters.startDate) queryParams.append('startDate', filters.startDate)
|
||||
if (filters.endDate) queryParams.append('endDate', filters.endDate)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/stats?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch operation log stats:', error)
|
||||
// 降级处理:返回mock统计数据
|
||||
return this.getMockStats()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock数据 - 用于降级处理
|
||||
*/
|
||||
private static getMockData(params: OperationLogListParams): PaginatedResponse<OperationLog> {
|
||||
const mockLogs: OperationLog[] = [
|
||||
{
|
||||
id: 'op-1',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
operationTime: '2024-10-21T09:35:00',
|
||||
module: 'user',
|
||||
action: 'create',
|
||||
description: '创建用户账号: zhangsan',
|
||||
ipAddress: '192.168.1.100',
|
||||
requestUrl: '/api/users',
|
||||
requestMethod: 'POST',
|
||||
requestParams: JSON.stringify({ username: 'zhangsan', name: '张三' }),
|
||||
duration: 150,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'op-2',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
operationTime: '2024-10-21T10:20:00',
|
||||
module: 'machinery',
|
||||
action: 'update',
|
||||
description: '更新农机信息: 约翰迪尔拖拉机',
|
||||
ipAddress: '192.168.1.101',
|
||||
requestUrl: '/api/machinery/123',
|
||||
requestMethod: 'PUT',
|
||||
duration: 89,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'op-3',
|
||||
userId: 'user-3',
|
||||
username: 'lisi',
|
||||
operationTime: '2024-10-21T11:25:00',
|
||||
module: 'role',
|
||||
action: 'delete',
|
||||
description: '删除角色: 临时操作员',
|
||||
ipAddress: '192.168.1.102',
|
||||
requestUrl: '/api/roles/456',
|
||||
requestMethod: 'DELETE',
|
||||
duration: 120,
|
||||
status: 'failed',
|
||||
errorMessage: '该角色下仍有关联用户,无法删除',
|
||||
},
|
||||
{
|
||||
id: 'op-4',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
operationTime: '2024-10-21T14:50:00',
|
||||
module: 'system',
|
||||
action: 'update',
|
||||
description: '修改系统配置: 会话超时时间',
|
||||
ipAddress: '192.168.1.100',
|
||||
requestUrl: '/api/system/settings',
|
||||
requestMethod: 'PUT',
|
||||
duration: 95,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'op-5',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
operationTime: '2024-10-21T15:35:00',
|
||||
module: 'task',
|
||||
action: 'create',
|
||||
description: '创建作业任务: 小麦播种作业',
|
||||
ipAddress: '192.168.1.101',
|
||||
requestUrl: '/api/tasks',
|
||||
requestMethod: 'POST',
|
||||
duration: 180,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'op-6',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
operationTime: '2024-10-21T16:15:00',
|
||||
module: 'user',
|
||||
action: 'export',
|
||||
description: '导出用户列表数据',
|
||||
ipAddress: '192.168.1.100',
|
||||
requestUrl: '/api/users/export',
|
||||
requestMethod: 'GET',
|
||||
duration: 1250,
|
||||
status: 'success',
|
||||
},
|
||||
]
|
||||
|
||||
// 应用筛选器
|
||||
let filteredLogs = mockLogs.filter(log => {
|
||||
if (params.filters?.searchKeyword) {
|
||||
const keyword = params.filters.searchKeyword.toLowerCase()
|
||||
if (!log.username.toLowerCase().includes(keyword) &&
|
||||
!log.description.toLowerCase().includes(keyword) &&
|
||||
!log.module.toLowerCase().includes(keyword)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (params.filters?.module && params.filters.module !== 'all') {
|
||||
if (log.module !== params.filters.module) return false
|
||||
}
|
||||
|
||||
if (params.filters?.action && params.filters.action !== 'all') {
|
||||
if (log.action !== params.filters.action) return false
|
||||
}
|
||||
|
||||
if (params.filters?.status && params.filters.status !== 'all') {
|
||||
if (log.status !== params.filters.status) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 应用分页
|
||||
const page = params.page || 1
|
||||
const pageSize = params.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const endIndex = startIndex + pageSize
|
||||
const paginatedLogs = filteredLogs.slice(startIndex, endIndex)
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
success: true,
|
||||
data: paginatedLogs,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total: filteredLogs.length,
|
||||
totalPages: Math.ceil(filteredLogs.length / pageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static getMockStats(): ApiResponse<{
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
averageDuration: number
|
||||
moduleStats: Array<{ module: string, count: number }>
|
||||
actionStats: Array<{ action: string, count: number }>
|
||||
}> {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
success: true,
|
||||
data: {
|
||||
total: 6,
|
||||
success: 5,
|
||||
failed: 1,
|
||||
averageDuration: 314,
|
||||
moduleStats: [
|
||||
{ module: 'user', count: 2 },
|
||||
{ module: 'machinery', count: 1 },
|
||||
{ module: 'role', count: 1 },
|
||||
{ module: 'system', count: 1 },
|
||||
{ module: 'task', count: 1 }
|
||||
],
|
||||
actionStats: [
|
||||
{ action: 'create', count: 2 },
|
||||
{ action: 'update', count: 2 },
|
||||
{ action: 'delete', count: 1 },
|
||||
{ action: 'export', count: 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { OperationLog } from '@/types/monitor'
|
||||
|
||||
interface OperationLogStatsProps {
|
||||
logs: OperationLog[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function OperationLogStats({ logs, isLoading = false }: OperationLogStatsProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: '总操作数',
|
||||
value: logs.length,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '成功操作',
|
||||
value: logs.filter(l => l.status === 'success').length,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '失败操作',
|
||||
value: logs.filter(l => l.status === 'failed').length,
|
||||
color: 'text-red-600',
|
||||
},
|
||||
{
|
||||
label: '平均耗时',
|
||||
value: logs.length > 0
|
||||
? Math.round(logs.reduce((sum, l) => sum + (l.duration || 0), 0) / logs.length) + 'ms'
|
||||
: '0ms',
|
||||
color: 'text-purple-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
<div className={`mt-2 text-2xl font-semibold ${stat.color}`}>
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse bg-gray-200 h-8 w-16 rounded"></div>
|
||||
) : (
|
||||
stat.value
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { OperationLog } from '@/types/monitor'
|
||||
import { Eye } from 'lucide-react'
|
||||
|
||||
interface OperationLogTableProps {
|
||||
logs: OperationLog[]
|
||||
isLoading?: boolean
|
||||
onViewDetail: (log: OperationLog) => void
|
||||
}
|
||||
|
||||
export function OperationLogTable({ logs, isLoading = false, onViewDetail }: OperationLogTableProps) {
|
||||
const getModuleLabel = (module: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
user: '用户管理',
|
||||
role: '角色管理',
|
||||
permission: '权限管理',
|
||||
machinery: '农机管理',
|
||||
driver: '驾驶员管理',
|
||||
task: '任务管理',
|
||||
system: '系统配置',
|
||||
other: '其他',
|
||||
}
|
||||
return labels[module] || module
|
||||
}
|
||||
|
||||
const getActionLabel = (action: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
create: '新增',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
view: '查看',
|
||||
export: '导出',
|
||||
import: '导入',
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
}
|
||||
return labels[action] || action
|
||||
}
|
||||
|
||||
const getActionBadge = (action: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
create: 'bg-green-100 text-green-700',
|
||||
update: 'bg-blue-100 text-blue-700',
|
||||
delete: 'bg-red-100 text-red-700',
|
||||
view: 'bg-gray-100 text-gray-700',
|
||||
export: 'bg-purple-100 text-purple-700',
|
||||
import: 'bg-yellow-100 text-yellow-700',
|
||||
}
|
||||
return colors[action] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-8 space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="flex space-x-4">
|
||||
<div className="bg-gray-200 h-4 w-20 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-32 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-24 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-40 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-8 w-8 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>操作人</TableHead>
|
||||
<TableHead>操作时间</TableHead>
|
||||
<TableHead>模块</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>耗时</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||
暂无操作日志
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>{log.username}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{new Date(log.operationTime).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{getModuleLabel(log.module)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getActionBadge(log.action)}>
|
||||
{getActionLabel(log.action)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate" title={log.description}>
|
||||
{log.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{log.duration ? `${log.duration}ms` : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.status === 'success' ? (
|
||||
<Badge className="bg-green-100 text-green-700">成功</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-100 text-red-700">失败</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewDetail(log)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { OperationLogService } from './OperationLogService'
|
||||
export { OperationLogStats } from './OperationLogStats'
|
||||
export { OperationLogFilters } from './OperationLogFilters'
|
||||
export { OperationLogTable } from './OperationLogTable'
|
||||
export { OperationLogDetailDialog } from './OperationLogDetailDialog'
|
||||
export { OperationLogInfo } from './OperationLogInfo'
|
||||
|
||||
export type { OperationLogListParams } from './OperationLogService'
|
||||
export type { OperationLogFilters as OperationLogFiltersType } from './OperationLogService'
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { OperationLog } from '@/types/monitor'
|
||||
import { Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Import modular components
|
||||
import {
|
||||
OperationLogService,
|
||||
OperationLogStats,
|
||||
OperationLogFilters,
|
||||
OperationLogTable,
|
||||
OperationLogDetailDialog,
|
||||
OperationLogInfo
|
||||
} from './components'
|
||||
|
||||
export default function OperationLogPage() {
|
||||
const [logs, setLogs] = useState<OperationLog[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [moduleFilter, setModuleFilter] = useState<string>('all')
|
||||
const [actionFilter, setActionFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [showDetailDialog, setShowDetailDialog] = useState(false)
|
||||
const [selectedLog, setSelectedLog] = useState<OperationLog | null>(null)
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [searchKeyword, moduleFilter, actionFilter, statusFilter])
|
||||
|
||||
const loadLogs = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await OperationLogService.getOperationLogs({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: {
|
||||
searchKeyword,
|
||||
module: moduleFilter,
|
||||
action: actionFilter,
|
||||
status: statusFilter
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
setLogs(response.data)
|
||||
} else {
|
||||
throw new Error(response.message || '加载操作日志失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load operation logs:', error)
|
||||
toast.error('加载操作日志失败,请稍后重试')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = async (log: OperationLog) => {
|
||||
setSelectedLog(log)
|
||||
setShowDetailDialog(true)
|
||||
|
||||
setIsDetailLoading(true)
|
||||
try {
|
||||
const response = await OperationLogService.getOperationLogDetail(log.id)
|
||||
if (response.success) {
|
||||
setSelectedLog(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch log detail:', error)
|
||||
} finally {
|
||||
setIsDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await OperationLogService.exportOperationLogs({
|
||||
searchKeyword,
|
||||
module: moduleFilter,
|
||||
action: actionFilter,
|
||||
status: statusFilter
|
||||
})
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `operation_logs_${new Date().getTime()}.json`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success('导出成功')
|
||||
} catch (error) {
|
||||
console.error('Failed to export logs:', error)
|
||||
toast.error('导出失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">操作日志</h2>
|
||||
<p className="text-muted-foreground">详细追踪用户在系统中的关键操作行为</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={isLoading || logs.length === 0}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出日志
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OperationLogStats logs={logs} isLoading={isLoading} />
|
||||
|
||||
<OperationLogFilters
|
||||
searchKeyword={searchKeyword}
|
||||
onSearchChange={setSearchKeyword}
|
||||
moduleFilter={moduleFilter}
|
||||
onModuleFilterChange={setModuleFilter}
|
||||
actionFilter={actionFilter}
|
||||
onActionFilterChange={setActionFilter}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
/>
|
||||
|
||||
<OperationLogTable
|
||||
logs={logs}
|
||||
isLoading={isLoading}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
|
||||
<OperationLogDetailDialog
|
||||
log={selectedLog}
|
||||
isOpen={showDetailDialog}
|
||||
onClose={() => setShowDetailDialog(false)}
|
||||
isLoading={isDetailLoading}
|
||||
/>
|
||||
|
||||
<OperationLogInfo />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
crop-x/src/app/(app)/central-config/monitor/page.tsx
Normal file
30
crop-x/src/app/(app)/central-config/monitor/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function MonitorPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">系统监控</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Link href="/central-config/monitor/login-log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">登录日志</h3>
|
||||
<p className="text-gray-600 text-sm">查看用户登录记录</p>
|
||||
</Link>
|
||||
<Link href="/central-config/monitor/operation-log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">操作日志</h3>
|
||||
<p className="text-gray-600 text-sm">查看系统操作记录</p>
|
||||
</Link>
|
||||
<Link href="/central-config/monitor/performance" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">性能监控</h3>
|
||||
<p className="text-gray-600 text-sm">监控系统性能</p>
|
||||
</Link>
|
||||
<Link href="/central-config/monitor/network-log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">网络日志</h3>
|
||||
<p className="text-gray-600 text-sm">查看网络访问日志</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface CpuMetricCardProps {
|
||||
performance: SystemPerformance;
|
||||
getUsageColor: (usage: number) => string;
|
||||
getUsageStatus: (usage: number) => string;
|
||||
}
|
||||
|
||||
export function CpuMetricCard({ performance, getUsageColor, getUsageStatus }: CpuMetricCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-blue-600" />
|
||||
<h3>CPU使用率</h3>
|
||||
</div>
|
||||
<Badge className={getUsageColor(performance.cpu.usage)}>
|
||||
{getUsageStatus(performance.cpu.usage)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">使用率</span>
|
||||
<span className={`${getUsageColor(performance.cpu.usage)}`}>
|
||||
{performance.cpu.usage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={performance.cpu.usage} className="h-2" />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">核心数</span>
|
||||
<span>{performance.cpu.cores} 核</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface DiskMetricCardProps {
|
||||
performance: SystemPerformance;
|
||||
getUsageColor: (usage: number) => string;
|
||||
getUsageStatus: (usage: number) => string;
|
||||
formatBytes: (bytes: number) => string;
|
||||
}
|
||||
|
||||
export function DiskMetricCard({ performance, getUsageColor, getUsageStatus, formatBytes }: DiskMetricCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="w-5 h-5 text-purple-600" />
|
||||
<h3>磁盘使用率</h3>
|
||||
</div>
|
||||
<Badge className={getUsageColor(performance.disk.usage)}>
|
||||
{getUsageStatus(performance.disk.usage)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">使用率</span>
|
||||
<span className={getUsageColor(performance.disk.usage)}>
|
||||
{performance.disk.usage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={performance.disk.usage} className="h-2" />
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">已用</span>
|
||||
<p>{formatBytes(performance.disk.used)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">可用</span>
|
||||
<p>{formatBytes(performance.disk.free)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">总量</span>
|
||||
<p>{formatBytes(performance.disk.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Server } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface JvmInfoCardProps {
|
||||
performance: SystemPerformance;
|
||||
formatBytes: (bytes: number) => string;
|
||||
}
|
||||
|
||||
export function JvmInfoCard({ performance, formatBytes }: JvmInfoCardProps) {
|
||||
if (!performance.jvm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Server className="w-5 h-5 text-orange-600" />
|
||||
<h3>JVM信息</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">堆内存使用</p>
|
||||
<p className="mt-1">{performance.jvm.heapUsage.toFixed(1)}%</p>
|
||||
<Progress value={performance.jvm.heapUsage} className="h-1 mt-2" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">堆内存</p>
|
||||
<p className="mt-1">
|
||||
{formatBytes(performance.jvm.heapUsed)} / {formatBytes(performance.jvm.heapMax)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">非堆内存</p>
|
||||
<p className="mt-1">{formatBytes(performance.jvm.nonHeapUsed)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">线程数</p>
|
||||
<p className="mt-1">{performance.jvm.threadCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">GC次数</p>
|
||||
<p className="mt-1">{performance.jvm.gcCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">GC耗时</p>
|
||||
<p className="mt-1">{performance.jvm.gcTime}ms</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { MemoryStick } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface MemoryMetricCardProps {
|
||||
performance: SystemPerformance;
|
||||
getUsageColor: (usage: number) => string;
|
||||
getUsageStatus: (usage: number) => string;
|
||||
formatBytes: (bytes: number) => string;
|
||||
}
|
||||
|
||||
export function MemoryMetricCard({ performance, getUsageColor, getUsageStatus, formatBytes }: MemoryMetricCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MemoryStick className="w-5 h-5 text-green-600" />
|
||||
<h3>内存使用率</h3>
|
||||
</div>
|
||||
<Badge className={getUsageColor(performance.memory.usage)}>
|
||||
{getUsageStatus(performance.memory.usage)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">使用率</span>
|
||||
<span className={getUsageColor(performance.memory.usage)}>
|
||||
{performance.memory.usage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={performance.memory.usage} className="h-2" />
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">已用</span>
|
||||
<p>{formatBytes(performance.memory.used)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">总量</span>
|
||||
<p>{formatBytes(performance.memory.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Activity } from 'lucide-react';
|
||||
|
||||
export function PerformanceInstructions() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<Activity className="w-4 h-4 inline mr-2" />
|
||||
性能监控说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 系统每5秒自动刷新一次性能数据</li>
|
||||
<li>• 使用率超过60%显示警告,超过80%显示危险</li>
|
||||
<li>• 趋势图显示最近20次的性能数据变化</li>
|
||||
<li>• 建议在性能使用率持续偏高时进行系统优化</li>
|
||||
<li>• JVM和Tomcat信息仅在Java环境下可用</li>
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface PerformanceTrendChartProps {
|
||||
history: SystemPerformance[];
|
||||
}
|
||||
|
||||
export function PerformanceTrendChart({ history }: PerformanceTrendChartProps) {
|
||||
if (history.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chartData = history.map((item) => ({
|
||||
time: new Date(item.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}),
|
||||
CPU: item.cpu.usage.toFixed(1),
|
||||
内存: item.memory.usage.toFixed(1),
|
||||
磁盘: item.disk.usage.toFixed(1),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">性能趋势</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="CPU" stroke="#3b82f6" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="内存" stroke="#10b981" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="磁盘" stroke="#a855f7" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface TomcatInfoCardProps {
|
||||
performance: SystemPerformance;
|
||||
}
|
||||
|
||||
export function TomcatInfoCard({ performance }: TomcatInfoCardProps) {
|
||||
if (!performance.tomcat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="w-5 h-5 text-red-600" />
|
||||
<h3>Tomcat信息</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">线程数</p>
|
||||
<p className="mt-1">
|
||||
{performance.tomcat.threadCount} / {performance.tomcat.maxThreads}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">连接数</p>
|
||||
<p className="mt-1">{performance.tomcat.connectionCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求数</p>
|
||||
<p className="mt-1">{performance.tomcat.requestCount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">错误数</p>
|
||||
<p className="mt-1 text-red-600">{performance.tomcat.errorCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">错误率</p>
|
||||
<p className="mt-1">
|
||||
{((performance.tomcat.errorCount / performance.tomcat.requestCount) * 100).toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { CpuMetricCard } from './CpuMetricCard';
|
||||
export { MemoryMetricCard } from './MemoryMetricCard';
|
||||
export { DiskMetricCard } from './DiskMetricCard';
|
||||
export { JvmInfoCard } from './JvmInfoCard';
|
||||
export { TomcatInfoCard } from './TomcatInfoCard';
|
||||
export { PerformanceTrendChart } from './PerformanceTrendChart';
|
||||
export { PerformanceInstructions } from './PerformanceInstructions';
|
||||
274
crop-x/src/app/(app)/central-config/monitor/performance/page.tsx
Normal file
274
crop-x/src/app/(app)/central-config/monitor/performance/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
import {
|
||||
CpuMetricCard,
|
||||
MemoryMetricCard,
|
||||
DiskMetricCard,
|
||||
JvmInfoCard,
|
||||
TomcatInfoCard,
|
||||
PerformanceTrendChart,
|
||||
PerformanceInstructions
|
||||
} from './components';
|
||||
|
||||
// API服务函数
|
||||
const performanceApi = {
|
||||
// 获取当前性能数据
|
||||
getCurrentPerformance: async (): Promise<SystemPerformance> => {
|
||||
try {
|
||||
const response = await fetch('/api/monitor/performance/current');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch performance data');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch performance data, using mock data:', error);
|
||||
// 如果API调用失败,返回模拟数据
|
||||
return getMockPerformanceData();
|
||||
}
|
||||
},
|
||||
|
||||
// 获取历史性能数据
|
||||
getPerformanceHistory: async (limit: number = 20): Promise<SystemPerformance[]> => {
|
||||
try {
|
||||
const response = await fetch(`/api/monitor/performance/history?limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch performance history');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch performance history, using mock data:', error);
|
||||
// 如果API调用失败,返回模拟历史数据
|
||||
return Array.from({ length: Math.min(5, limit) }, (_, i) => {
|
||||
const mockData = getMockPerformanceData();
|
||||
mockData.timestamp = new Date(Date.now() - (4 - i) * 5000).toISOString();
|
||||
return mockData;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟数据生成函数
|
||||
function getMockPerformanceData(): SystemPerformance {
|
||||
const mockData: SystemPerformance = {
|
||||
timestamp: new Date().toISOString(),
|
||||
cpu: {
|
||||
usage: Math.random() * 60 + 20, // 20-80%
|
||||
cores: 8,
|
||||
},
|
||||
memory: {
|
||||
total: 16384, // 16GB
|
||||
used: Math.random() * 8192 + 4096, // 4-12GB
|
||||
free: 0,
|
||||
usage: 0,
|
||||
},
|
||||
disk: {
|
||||
total: 512, // 512GB
|
||||
used: Math.random() * 102 + 204, // 204-306GB
|
||||
free: 0,
|
||||
usage: 0,
|
||||
},
|
||||
jvm: {
|
||||
heapUsed: Math.random() * 1024 + 512, // 512-1536MB
|
||||
heapMax: 2048, // 2GB
|
||||
heapUsage: 0,
|
||||
nonHeapUsed: Math.random() * 100 + 50,
|
||||
threadCount: Math.floor(Math.random() * 50 + 100),
|
||||
gcCount: Math.floor(Math.random() * 10 + 50),
|
||||
gcTime: Math.floor(Math.random() * 200 + 100),
|
||||
},
|
||||
tomcat: {
|
||||
threadCount: Math.floor(Math.random() * 50 + 50),
|
||||
maxThreads: 200,
|
||||
connectionCount: Math.floor(Math.random() * 100 + 50),
|
||||
requestCount: Math.floor(Math.random() * 10000 + 50000),
|
||||
errorCount: Math.floor(Math.random() * 10),
|
||||
},
|
||||
};
|
||||
|
||||
// 计算百分比
|
||||
mockData.memory.free = mockData.memory.total - mockData.memory.used;
|
||||
mockData.memory.usage = (mockData.memory.used / mockData.memory.total) * 100;
|
||||
mockData.disk.free = mockData.disk.total - mockData.disk.used;
|
||||
mockData.disk.usage = (mockData.disk.used / mockData.disk.total) * 100;
|
||||
if (mockData.jvm) {
|
||||
mockData.jvm.heapUsage = (mockData.jvm.heapUsed / mockData.jvm.heapMax) * 100;
|
||||
}
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default function PerformanceMonitorPage() {
|
||||
const [performance, setPerformance] = useState<SystemPerformance | null>(null);
|
||||
const [history, setHistory] = useState<SystemPerformance[]>([]);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadPerformance();
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefresh) {
|
||||
loadPerformance();
|
||||
}
|
||||
}, 5000); // 每5秒刷新一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh]);
|
||||
|
||||
const loadPerformance = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
// 获取当前数据
|
||||
const currentData = await performanceApi.getCurrentPerformance();
|
||||
|
||||
setPerformance(currentData);
|
||||
|
||||
// 保存历史数据(最多保留20条)
|
||||
setHistory(prev => {
|
||||
const newHistory = [...prev, currentData].slice(-20);
|
||||
return newHistory;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载性能数据失败');
|
||||
console.error('Failed to load performance data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUsageColor = (usage: number) => {
|
||||
if (usage < 60) return 'text-green-600';
|
||||
if (usage < 80) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getUsageStatus = (usage: number) => {
|
||||
if (usage < 60) return '正常';
|
||||
if (usage < 80) return '警告';
|
||||
return '危险';
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes.toFixed(2)} MB`;
|
||||
return `${(bytes / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
if (loading && !performance) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-green-600 mx-auto mb-2" />
|
||||
<p className="text-muted-foreground">正在加载性能数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !performance) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">加载失败: {error}</p>
|
||||
<Button onClick={loadPerformance} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!performance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题和控制按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">性能监控</h2>
|
||||
<p className="text-muted-foreground">实时监控系统运行健康状态</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={autoRefresh ? 'default' : 'outline'}>
|
||||
{autoRefresh ? '自动刷新' : '已暂停'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
>
|
||||
{autoRefresh ? '暂停' : '启动'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadPerformance}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 border border-yellow-200 bg-yellow-50 rounded-md">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
警告: {error} (当前显示为模拟数据)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CPU和内存卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<CpuMetricCard
|
||||
performance={performance}
|
||||
getUsageColor={getUsageColor}
|
||||
getUsageStatus={getUsageStatus}
|
||||
/>
|
||||
<MemoryMetricCard
|
||||
performance={performance}
|
||||
getUsageColor={getUsageColor}
|
||||
getUsageStatus={getUsageStatus}
|
||||
formatBytes={formatBytes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 磁盘卡片 */}
|
||||
<DiskMetricCard
|
||||
performance={performance}
|
||||
getUsageColor={getUsageColor}
|
||||
getUsageStatus={getUsageStatus}
|
||||
formatBytes={formatBytes}
|
||||
/>
|
||||
|
||||
{/* JVM信息卡片 */}
|
||||
<JvmInfoCard
|
||||
performance={performance}
|
||||
formatBytes={formatBytes}
|
||||
/>
|
||||
|
||||
{/* Tomcat信息卡片 */}
|
||||
<TomcatInfoCard
|
||||
performance={performance}
|
||||
/>
|
||||
|
||||
{/* 性能趋势图 */}
|
||||
<PerformanceTrendChart
|
||||
history={history}
|
||||
/>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<PerformanceInstructions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user