优化页面
This commit is contained in:
1 parent
1f365604a5
commit
4cf4186b83
6 files changed
+579
-736
No files matched your search
@@ -1,21 +1,35 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useNavigate, useParams} from 'react-router-dom';
|
||||
import type {MenuProps} from 'antd';
|
||||
import {App, Button, Card, Col, Descriptions, Dropdown, Row, Space, Spin, Tag} from 'antd';
|
||||
import {Activity, ArrowLeft, Clock, RefreshCw, Shield, Terminal} from 'lucide-react';
|
||||
import {useNavigate, useParams, useSearchParams} from 'react-router-dom';
|
||||
import type {MenuProps, TabsProps} from 'antd';
|
||||
import {App, Button, Card, Col, Descriptions, Dropdown, Row, Space, Spin, Tag, Tabs, Alert, Statistic} from 'antd';
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Terminal,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import {getAgentForAdmin, getAuditResult, sendAuditCommand, type VPSAuditResult} from '../../api/agent';
|
||||
import type {Agent} from '../../types';
|
||||
import dayjs from 'dayjs';
|
||||
import {getErrorMessage} from '../../lib/utils';
|
||||
import AuditResultView from './AuditResultView';
|
||||
|
||||
const AgentDetail = () => {
|
||||
const {id} = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const {message: messageApi} = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [auditResult, setAuditResult] = useState<VPSAuditResult | null>(null);
|
||||
const [auditing, setAuditing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<string>(searchParams.get('tab') || 'info');
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!id) return;
|
||||
@@ -39,6 +53,12 @@ const AgentDetail = () => {
|
||||
const handleStartAudit = async () => {
|
||||
if (!id) return;
|
||||
|
||||
// 检查是否为 Linux 系统
|
||||
if (!agent?.os.toLowerCase().includes('linux')) {
|
||||
messageApi.warning('安全审计功能仅支持 Linux 系统');
|
||||
return;
|
||||
}
|
||||
|
||||
setAuditing(true);
|
||||
try {
|
||||
await sendAuditCommand(id);
|
||||
@@ -59,6 +79,11 @@ const AgentDetail = () => {
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
// 同步 activeTab 到 URL
|
||||
setSearchParams({tab: activeTab});
|
||||
}, [activeTab]);
|
||||
|
||||
if (loading && !agent) {
|
||||
return (
|
||||
<div className="text-center py-24">
|
||||
@@ -67,7 +92,7 @@ const AgentDetail = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// 命令菜单配置 - 方便扩展新命令
|
||||
// 命令菜单配置
|
||||
const commandMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'audit',
|
||||
@@ -84,19 +109,200 @@ const AgentDetail = () => {
|
||||
label: '刷新数据',
|
||||
onClick: fetchData,
|
||||
},
|
||||
// 可以在这里添加更多命令
|
||||
// {
|
||||
// key: 'restart',
|
||||
// icon: <RotateCw size={16} />,
|
||||
// label: '重启探针',
|
||||
// onClick: handleRestartAgent,
|
||||
// },
|
||||
// {
|
||||
// key: 'update',
|
||||
// icon: <Download size={16} />,
|
||||
// label: '更新探针',
|
||||
// onClick: handleUpdateAgent,
|
||||
// },
|
||||
];
|
||||
|
||||
// 计算审计统计
|
||||
const passCount = auditResult?.securityChecks.filter(c => c.status === 'pass').length || 0;
|
||||
const failCount = auditResult?.securityChecks.filter(c => c.status === 'fail').length || 0;
|
||||
const warnCount = auditResult?.securityChecks.filter(c => c.status === 'warn').length || 0;
|
||||
const totalCount = auditResult?.securityChecks.length || 0;
|
||||
|
||||
// Tab 项配置
|
||||
const tabItems: TabsProps['items'] = [
|
||||
{
|
||||
key: 'info',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Activity size={16}/>
|
||||
基本信息
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Descriptions column={{xs: 1, sm: 2}} bordered>
|
||||
<Descriptions.Item label="探针名称">{agent?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="探针ID">{agent?.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="主机名">{agent?.hostname}</Descriptions.Item>
|
||||
<Descriptions.Item label="IP地址">{agent?.ip}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作系统">
|
||||
<Tag color="blue">{agent?.os}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="系统架构">
|
||||
<Tag>{agent?.arch}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台">
|
||||
{agent?.platform ? <Tag color="purple">{agent.platform}</Tag> : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="位置">
|
||||
{agent?.location ? <Tag color="blue">{agent.location}</Tag> : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="到期时间">
|
||||
{agent?.expireTime ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>{new Date(agent.expireTime).toLocaleDateString('zh-CN')}</div>
|
||||
{(() => {
|
||||
const expireDate = new Date(agent.expireTime);
|
||||
const now = new Date();
|
||||
const isExpired = expireDate < now;
|
||||
const daysLeft = Math.ceil((expireDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (isExpired) {
|
||||
return <Tag color="red" bordered={false}>已过期</Tag>;
|
||||
} else if (daysLeft <= 7) {
|
||||
return <Tag color="orange" bordered={false}>{daysLeft}天后到期</Tag>;
|
||||
} else if (daysLeft <= 30) {
|
||||
return <Tag color="gold" bordered={false}>{daysLeft}天后到期</Tag>;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="探针版本">{agent?.version}</Descriptions.Item>
|
||||
<Descriptions.Item label="最后活跃时间">
|
||||
<Space>
|
||||
<Clock size={14}/>
|
||||
{agent?.lastSeenAt && dayjs(agent.lastSeenAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{agent?.createdAt && dayjs(agent.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{agent?.updatedAt && dayjs(agent.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield size={16}/>
|
||||
安全审计
|
||||
{auditResult && failCount > 0 && (
|
||||
<Tag color="error" style={{marginLeft: 4}}>{failCount}</Tag>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Space direction="vertical">
|
||||
{/* 非 Linux 系统提示 */}
|
||||
{agent && !agent.os.toLowerCase().includes('linux') && (
|
||||
<Alert
|
||||
message="功能限制"
|
||||
description="安全审计功能仅支持 Linux 系统。当前系统为 Windows 或其他系统,无法使用此功能。"
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
{!auditResult ? (
|
||||
agent?.os.toLowerCase().includes('linux') ? (
|
||||
<Alert
|
||||
message="暂无审计结果"
|
||||
description={
|
||||
<Space direction="vertical">
|
||||
<span>该探针还没有进行过安全审计。点击右上角"下发命令"按钮,选择"安全审计"来执行首次审计。</span>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Shield size={16}/>}
|
||||
onClick={handleStartAudit}
|
||||
loading={auditing}
|
||||
>
|
||||
立即开始审计
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
{/* 审计概览 */}
|
||||
<Card bordered={false}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="总检查项"
|
||||
value={totalCount}
|
||||
prefix={<Shield size={18} className="lg:w-5 lg:h-5"/>}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="通过"
|
||||
value={passCount}
|
||||
valueStyle={{color: '#52c41a'}}
|
||||
prefix={<CheckCircle size={18} className="lg:w-5 lg:h-5"/>}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="失败"
|
||||
value={failCount}
|
||||
valueStyle={{color: '#ff4d4f'}}
|
||||
prefix={<XCircle size={18} className="lg:w-5 lg:h-5"/>}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="警告"
|
||||
value={warnCount}
|
||||
valueStyle={{color: '#faad14'}}
|
||||
prefix={<AlertTriangle size={18} className="lg:w-5 lg:h-5"/>}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12}>
|
||||
<Statistic
|
||||
title="风险评分"
|
||||
value={auditResult.riskScore}
|
||||
suffix="/ 100"
|
||||
valueStyle={{
|
||||
color: auditResult.riskScore >= 80 ? '#ff4d4f' :
|
||||
auditResult.riskScore >= 50 ? '#faad14' :
|
||||
auditResult.riskScore >= 20 ? '#1890ff' : '#52c41a'
|
||||
}}
|
||||
prefix={<Activity size={18} className="lg:w-5 lg:h-5"/>}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12}>
|
||||
<div className="text-sm text-gray-500 mb-2">威胁等级</div>
|
||||
<Tag
|
||||
color={
|
||||
auditResult.threatLevel === 'critical' || auditResult.threatLevel === 'high' ? 'error' :
|
||||
auditResult.threatLevel === 'medium' ? 'warning' : 'success'
|
||||
}
|
||||
className="text-base px-3 py-1"
|
||||
>
|
||||
{auditResult.threatLevel === 'critical' ? '严重风险' :
|
||||
auditResult.threatLevel === 'high' ? '高风险' :
|
||||
auditResult.threatLevel === 'medium' ? '中风险' : '低风险'}
|
||||
</Tag>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
最近审计时间: {dayjs(auditResult.startTime).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 详细审计结果 */}
|
||||
<AuditResultView result={auditResult}/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -112,16 +318,6 @@ const AgentDetail = () => {
|
||||
</Button>
|
||||
|
||||
<Space size={8}>
|
||||
{auditResult && (
|
||||
<Button
|
||||
icon={<Shield size={16}/>}
|
||||
onClick={() => navigate(`/admin/agents/${id}/audit`)}
|
||||
size="middle"
|
||||
>
|
||||
<span className="hidden sm:inline">查看审计结果</span>
|
||||
<span className="sm:hidden">审计</span>
|
||||
</Button>
|
||||
)}
|
||||
<Dropdown
|
||||
menu={{items: commandMenuItems}}
|
||||
placement="bottomRight"
|
||||
@@ -140,110 +336,47 @@ const AgentDetail = () => {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Activity size={20}/>
|
||||
<span className="font-semibold">探针信息</span>
|
||||
{agent?.status === 1 && <Tag color="success">在线</Tag>}
|
||||
{agent?.status !== 1 && <Tag color="error">离线</Tag>}
|
||||
</Space>
|
||||
}
|
||||
className="rounded-lg border border-gray-200 mt-0"
|
||||
>
|
||||
<Descriptions column={{xs: 1, sm: 2}} bordered>
|
||||
<Descriptions.Item label="探针名称">{agent?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="探针ID">{agent?.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="主机名">{agent?.hostname}</Descriptions.Item>
|
||||
<Descriptions.Item label="IP地址">{agent?.ip}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作系统">
|
||||
<Tag color="blue">{agent?.os}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="系统架构">
|
||||
<Tag>{agent?.arch}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="探针版本">{agent?.version}</Descriptions.Item>
|
||||
<Descriptions.Item label="最后活跃时间">
|
||||
<Space>
|
||||
<Clock size={14}/>
|
||||
{agent?.lastSeenAt && dayjs(agent.lastSeenAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
{/* 探针状态卡片 */}
|
||||
<Card variant={'outlined'}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Activity size={32} className="text-blue-500"/>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold m-0">{agent?.name || agent?.hostname}</h2>
|
||||
<Space className="mt-1">
|
||||
<span className="text-gray-500">{agent?.hostname}</span>
|
||||
{agent?.status === 1 ? (
|
||||
<Tag color="success">在线</Tag>
|
||||
) : (
|
||||
<Tag color="error">离线</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{agent?.createdAt && dayjs(agent.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{agent?.updatedAt && dayjs(agent.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{auditResult && (
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-500">安全评分</div>
|
||||
<div className={`text-3xl font-bold ${
|
||||
auditResult.riskScore >= 80 ? 'text-red-500' :
|
||||
auditResult.riskScore >= 50 ? 'text-orange-500' :
|
||||
auditResult.riskScore >= 20 ? 'text-blue-500' : 'text-green-500'
|
||||
}`}>
|
||||
{100 - auditResult.riskScore}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 审计结果预览 */}
|
||||
<div>
|
||||
{auditResult && (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Shield size={20}/>
|
||||
<span className="font-semibold">最近审计结果</span>
|
||||
<Tag color="blue">
|
||||
{dayjs(auditResult.startTime).format('YYYY-MM-DD HH:mm')}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
className="rounded-lg border border-gray-200 mt-4 lg:mt-6"
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => navigate(`/admin/agents/${id}/audit`)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-semibold text-gray-900">
|
||||
{auditResult.securityChecks.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">总检查项</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-semibold text-emerald-600">
|
||||
{auditResult.securityChecks.filter(c => c.status === 'pass').length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">通过</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-semibold text-rose-600">
|
||||
{auditResult.securityChecks.filter(c => c.status === 'fail').length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">失败</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-semibold text-orange-600">
|
||||
{auditResult.securityChecks.filter(c => c.status === 'warn').length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">警告</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{/* Tabs 内容 */}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentDetail;
|
||||
|
||||
@@ -3,7 +3,7 @@ import {useNavigate} from 'react-router-dom';
|
||||
import type {ActionType, ProColumns} from '@ant-design/pro-components';
|
||||
import {ProTable} from '@ant-design/pro-components';
|
||||
import {App, Button, DatePicker, Divider, Form, Input, Modal, Space, Tag} from 'antd';
|
||||
import {Edit, Eye, RefreshCw} from 'lucide-react';
|
||||
import {Edit, Eye, RefreshCw, Plus, Shield} from 'lucide-react';
|
||||
import {getAgentPaging, updateAgentInfo} from '../../api/agent';
|
||||
import type {Agent} from '../../types';
|
||||
import {getErrorMessage} from '../../lib/utils';
|
||||
@@ -31,6 +31,7 @@ const AgentList = () => {
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
|
||||
// 保存探针信息
|
||||
const handleSave = async () => {
|
||||
if (!currentAgent) return;
|
||||
@@ -167,10 +168,19 @@ const AgentList = () => {
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
valueType: 'option',
|
||||
width: 150,
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<Shield size={14}/>}
|
||||
onClick={() => navigate(`/admin/agents/${record.id}?tab=audit`)}
|
||||
style={{padding: 0, margin: 0, color: '#1890ff'}}
|
||||
title="安全审计"
|
||||
>
|
||||
审计
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<Edit size={14}/>}
|
||||
@@ -199,6 +209,13 @@ const AgentList = () => {
|
||||
title="探针管理"
|
||||
description="管理和监控系统探针状态"
|
||||
actions={[
|
||||
{
|
||||
key: 'register',
|
||||
label: '注册探针',
|
||||
icon: <Plus size={16}/>,
|
||||
onClick: () => navigate('/admin/agents/deploy-guide'),
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
key: 'refresh',
|
||||
label: '刷新',
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Spin,
|
||||
App,
|
||||
Descriptions,
|
||||
Tag,
|
||||
List,
|
||||
Collapse,
|
||||
Alert,
|
||||
Row,
|
||||
Col,
|
||||
Statistic
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Shield,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
MinusCircle,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
Hash,
|
||||
GitBranch,
|
||||
Clock,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { getAuditResult, sendAuditCommand, listAuditResults } from '../../api/agent';
|
||||
import type { VPSAuditResult, AuditResultSummary } from '../../api/agent';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
const AuditResult = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { message: messageApi } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [auditing, setAuditing] = useState(false);
|
||||
const [result, setResult] = useState<VPSAuditResult | null>(null);
|
||||
const [historyList, setHistoryList] = useState<AuditResultSummary[]>([]);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [resultRes, historyRes] = await Promise.all([
|
||||
getAuditResult(id).catch(() => ({ data: null })),
|
||||
listAuditResults(id),
|
||||
]);
|
||||
|
||||
setResult(resultRes.data);
|
||||
setHistoryList(historyRes.data.items || []);
|
||||
} catch (error: any) {
|
||||
// 如果没有审计结果,不显示错误
|
||||
if (error.response?.status !== 404) {
|
||||
messageApi.error(error.response?.data?.message || '获取审计结果失败');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
const handleStartAudit = async () => {
|
||||
if (!id) return;
|
||||
|
||||
setAuditing(true);
|
||||
try {
|
||||
await sendAuditCommand(id);
|
||||
messageApi.success('安全审计已启动,请稍后查看结果');
|
||||
|
||||
// 5秒后刷新结果
|
||||
setTimeout(() => {
|
||||
fetchData();
|
||||
}, 5000);
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.response?.data?.message || '启动审计失败');
|
||||
} finally {
|
||||
setAuditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return <CheckCircle size={16} className="text-green-500" />;
|
||||
case 'fail':
|
||||
return <XCircle size={16} className="text-red-500" />;
|
||||
case 'warn':
|
||||
return <AlertTriangle size={16} className="text-yellow-500" />;
|
||||
case 'skip':
|
||||
return <MinusCircle size={16} className="text-gray-400" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const configs = {
|
||||
pass: { color: 'success', text: '通过' },
|
||||
fail: { color: 'error', text: '失败' },
|
||||
warn: { color: 'warning', text: '警告' },
|
||||
skip: { color: 'default', text: '跳过' },
|
||||
};
|
||||
const config = configs[status as keyof typeof configs] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const getCategoryName = (category: string) => {
|
||||
const names: Record<string, string> = {
|
||||
'non_root_user': '非Root用户',
|
||||
'ufw_security': 'UFW防火墙',
|
||||
'ssh_security': 'SSH安全',
|
||||
'access_control': '访问控制',
|
||||
'port_security': '端口安全',
|
||||
'unattended_upgrades': '自动更新',
|
||||
'fail2ban': 'Fail2ban',
|
||||
'rootkit_detection': 'Rootkit检测',
|
||||
'suspicious_processes': '可疑进程检测',
|
||||
'listening_ports': '端口监听检查',
|
||||
'cron_jobs': '定时任务检查',
|
||||
'suspicious_files': '可疑文件检查',
|
||||
'system_accounts': '系统账户检查',
|
||||
'network_connections': '网络连接检查',
|
||||
'file_integrity': '文件完整性检查',
|
||||
'login_history': '登录历史检查',
|
||||
'immutable_files': '不可变文件检查',
|
||||
'suspicious_env_vars': '环境变量检查',
|
||||
};
|
||||
return names[category] || category;
|
||||
};
|
||||
|
||||
const getThreatLevelConfig = (level: string) => {
|
||||
const configs = {
|
||||
low: { color: 'success', text: '低风险', icon: CheckCircle },
|
||||
medium: { color: 'warning', text: '中风险', icon: AlertTriangle },
|
||||
high: { color: 'error', text: '高风险', icon: XCircle },
|
||||
critical: { color: 'error', text: '严重风险', icon: XCircle },
|
||||
};
|
||||
return configs[level as keyof typeof configs] || configs.low;
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const d = dayjs.duration(seconds, 'seconds');
|
||||
const days = Math.floor(d.asDays());
|
||||
const hours = d.hours();
|
||||
const minutes = d.minutes();
|
||||
return `${days}天 ${hours}小时 ${minutes}分钟`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-24">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const passCount = result?.securityChecks.filter(c => c.status === 'pass').length || 0;
|
||||
const failCount = result?.securityChecks.filter(c => c.status === 'fail').length || 0;
|
||||
const warnCount = result?.securityChecks.filter(c => c.status === 'warn').length || 0;
|
||||
const totalCount = result?.securityChecks.length || 0;
|
||||
|
||||
return (
|
||||
<Space direction={'vertical'} className={'w-full'}>
|
||||
{/* 页面头部 */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button
|
||||
icon={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate(`/admin/agents/${id}`)}
|
||||
size="middle"
|
||||
>
|
||||
返回详情
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={handleStartAudit}
|
||||
loading={auditing}
|
||||
size="middle"
|
||||
>
|
||||
{auditing ? '审计中...' : '开始审计'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!result && (
|
||||
<Alert
|
||||
message="暂无审计结果"
|
||||
description='点击上方"开始审计"按钮执行VPS安全审计'
|
||||
type="info"
|
||||
showIcon
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
{/* 统计信息 */}
|
||||
<Card className="rounded-lg border border-gray-200 mt-0">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Statistic
|
||||
title="总检查项"
|
||||
value={totalCount}
|
||||
prefix={<Shield size={18} className="lg:w-5 lg:h-5" />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Statistic
|
||||
title="通过"
|
||||
value={passCount}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
prefix={<CheckCircle size={18} className="lg:w-5 lg:h-5" />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Statistic
|
||||
title="失败"
|
||||
value={failCount}
|
||||
valueStyle={{ color: '#ff4d4f' }}
|
||||
prefix={<XCircle size={18} className="lg:w-5 lg:h-5" />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Statistic
|
||||
title="警告"
|
||||
value={warnCount}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
prefix={<AlertTriangle size={18} className="lg:w-5 lg:h-5" />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={4}>
|
||||
<Statistic
|
||||
title="风险评分"
|
||||
value={result.riskScore}
|
||||
suffix="/ 100"
|
||||
valueStyle={{
|
||||
color: result.riskScore >= 80 ? '#ff4d4f' :
|
||||
result.riskScore >= 50 ? '#faad14' :
|
||||
result.riskScore >= 20 ? '#1890ff' : '#52c41a'
|
||||
}}
|
||||
prefix={<Activity size={18} className="lg:w-5 lg:h-5" />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={4}>
|
||||
<div className="text-sm text-gray-500 mb-2">威胁等级</div>
|
||||
<Tag
|
||||
color={getThreatLevelConfig(result.threatLevel).color}
|
||||
className="text-base px-3 py-1"
|
||||
>
|
||||
{getThreatLevelConfig(result.threatLevel).text}
|
||||
</Tag>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 系统信息 */}
|
||||
<Card
|
||||
title={<span className="font-semibold">系统信息</span>}
|
||||
className="rounded-lg border border-gray-200 mt-4 lg:mt-6"
|
||||
>
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} bordered>
|
||||
<Descriptions.Item label="主机名">
|
||||
{result.systemInfo.hostname}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="操作系统">
|
||||
{result.systemInfo.os}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="内核版本">
|
||||
{result.systemInfo.kernelVersion}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运行时长">
|
||||
{formatUptime(result.systemInfo.uptime)}
|
||||
</Descriptions.Item>
|
||||
{result.systemInfo.publicIP && (
|
||||
<Descriptions.Item label="公网IP" span={2}>
|
||||
{result.systemInfo.publicIP}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="审计时间" span={2}>
|
||||
{dayjs(result.startTime).format('YYYY-MM-DD HH:mm:ss')} - {dayjs(result.endTime).format('HH:mm:ss')}
|
||||
<span className="ml-2 text-gray-500">
|
||||
(耗时: {((result.endTime - result.startTime) / 1000).toFixed(2)}秒)
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 安全检查结果 */}
|
||||
<Card
|
||||
title={<span className="font-semibold">安全检查结果</span>}
|
||||
className="rounded-lg border border-gray-200 mt-4 lg:mt-6"
|
||||
>
|
||||
<Collapse accordion>
|
||||
{result.securityChecks.map((check, index) => (
|
||||
<Panel
|
||||
key={index}
|
||||
header={
|
||||
<Space>
|
||||
{getStatusIcon(check.status)}
|
||||
<span className="font-medium">
|
||||
{getCategoryName(check.category)}
|
||||
</span>
|
||||
{getStatusTag(check.status)}
|
||||
<span className="text-gray-500 text-sm">
|
||||
{check.message}
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{check.details && check.details.length > 0 && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={check.details}
|
||||
renderItem={(detail) => (
|
||||
<List.Item className="flex-col items-start">
|
||||
<Space className="w-full">
|
||||
{getStatusIcon(detail.status)}
|
||||
<span className="font-mono text-sm">
|
||||
{detail.name}
|
||||
</span>
|
||||
{getStatusTag(detail.status)}
|
||||
<span className="text-gray-600 flex-1">
|
||||
{detail.message}
|
||||
</span>
|
||||
</Space>
|
||||
{detail.evidence && (
|
||||
<div className="ml-6 mt-2 p-3 bg-gray-50 rounded text-xs w-full">
|
||||
<div className="font-semibold mb-2 flex items-center gap-2">
|
||||
<FileText size={14} />
|
||||
证据信息
|
||||
{detail.evidence.riskLevel && (
|
||||
<Tag
|
||||
color={
|
||||
detail.evidence.riskLevel === 'high' ? 'error' :
|
||||
detail.evidence.riskLevel === 'medium' ? 'warning' : 'default'
|
||||
}
|
||||
className="ml-2"
|
||||
>
|
||||
{detail.evidence.riskLevel === 'high' ? '高危' :
|
||||
detail.evidence.riskLevel === 'medium' ? '中危' : '低危'}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{detail.evidence.filePath && (
|
||||
<div className="mb-1">
|
||||
<span className="font-medium">文件路径: </span>
|
||||
<code className="bg-white px-1">{detail.evidence.filePath}</code>
|
||||
</div>
|
||||
)}
|
||||
{detail.evidence.fileHash && (
|
||||
<div className="mb-1 flex items-start gap-1">
|
||||
<Hash size={12} className="mt-0.5" />
|
||||
<span className="font-medium">SHA256: </span>
|
||||
<code className="bg-white px-1 break-all">{detail.evidence.fileHash}</code>
|
||||
</div>
|
||||
)}
|
||||
{detail.evidence.timestamp && (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
<span className="font-medium">时间: </span>
|
||||
{dayjs(detail.evidence.timestamp).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
)}
|
||||
{detail.evidence.networkConn && (
|
||||
<div className="mb-1">
|
||||
<span className="font-medium">网络连接: </span>
|
||||
<code className="bg-white px-1">{detail.evidence.networkConn}</code>
|
||||
</div>
|
||||
)}
|
||||
{detail.evidence.processTree && detail.evidence.processTree.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium mb-1 flex items-center gap-1">
|
||||
<GitBranch size={12} />
|
||||
进程树:
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded font-mono text-xs overflow-x-auto">
|
||||
{detail.evidence.processTree.map((line, idx) => (
|
||||
<div key={idx} className="whitespace-nowrap">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
{/* 修复建议 */}
|
||||
{result.recommendations && result.recommendations.length > 0 && (
|
||||
<Card
|
||||
title={<span className="font-semibold">修复建议</span>}
|
||||
className="rounded-lg border border-gray-200 mt-4 lg:mt-6"
|
||||
>
|
||||
<List
|
||||
dataSource={result.recommendations}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item>
|
||||
<Space align="start">
|
||||
<span className="font-semibold text-gray-500">{index + 1}.</span>
|
||||
<span className={
|
||||
item.startsWith('【紧急】') ? 'text-red-600 font-medium' :
|
||||
item.startsWith('【警告】') ? 'text-yellow-600' :
|
||||
'text-gray-700'
|
||||
}>
|
||||
{item}
|
||||
</span>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 历史记录 */}
|
||||
{historyList.length > 0 && (
|
||||
<Card
|
||||
title={<span className="font-semibold">审计历史</span>}
|
||||
className="rounded-lg border border-gray-200 mt-4 lg:mt-6"
|
||||
>
|
||||
<List
|
||||
dataSource={historyList}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
<span>
|
||||
{dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</span>
|
||||
<Tag color="blue">{item.systemInfo.os}</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space size="large">
|
||||
<span>
|
||||
<CheckCircle size={14} className="inline mr-1 text-green-500" />
|
||||
通过: {item.passCount}
|
||||
</span>
|
||||
<span>
|
||||
<XCircle size={14} className="inline mr-1 text-red-500" />
|
||||
失败: {item.failCount}
|
||||
</span>
|
||||
<span>
|
||||
<AlertTriangle size={14} className="inline mr-1 text-yellow-500" />
|
||||
警告: {item.warnCount}
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditResult;
|
||||
@@ -0,0 +1,254 @@
|
||||
import {Card, Collapse, List, Space, Tag, Descriptions} from 'antd';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
MinusCircle,
|
||||
FileText,
|
||||
Hash,
|
||||
GitBranch,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import type {VPSAuditResult} from '../../api/agent';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
const {Panel} = Collapse;
|
||||
|
||||
interface AuditResultViewProps {
|
||||
result: VPSAuditResult;
|
||||
}
|
||||
|
||||
const AuditResultView = ({result}: AuditResultViewProps) => {
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return <CheckCircle size={16} className="text-green-500"/>;
|
||||
case 'fail':
|
||||
return <XCircle size={16} className="text-red-500"/>;
|
||||
case 'warn':
|
||||
return <AlertTriangle size={16} className="text-yellow-500"/>;
|
||||
case 'skip':
|
||||
return <MinusCircle size={16} className="text-gray-400"/>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const configs = {
|
||||
pass: {color: 'success', text: '通过'},
|
||||
fail: {color: 'error', text: '失败'},
|
||||
warn: {color: 'warning', text: '警告'},
|
||||
skip: {color: 'default', text: '跳过'},
|
||||
};
|
||||
const config = configs[status as keyof typeof configs] || {color: 'default', text: status};
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const getCategoryName = (category: string) => {
|
||||
const names: Record<string, string> = {
|
||||
'non_root_user': '非Root用户',
|
||||
'ufw_security': 'UFW防火墙',
|
||||
'ssh_security': 'SSH安全',
|
||||
'access_control': '访问控制',
|
||||
'port_security': '端口安全',
|
||||
'unattended_upgrades': '自动更新',
|
||||
'fail2ban': 'Fail2ban',
|
||||
'rootkit_detection': 'Rootkit检测',
|
||||
'suspicious_processes': '可疑进程检测',
|
||||
'listening_ports': '端口监听检查',
|
||||
'cron_jobs': '定时任务检查',
|
||||
'suspicious_files': '可疑文件检查',
|
||||
'system_accounts': '系统账户检查',
|
||||
'network_connections': '网络连接检查',
|
||||
'file_integrity': '文件完整性检查',
|
||||
'login_history': '登录历史检查',
|
||||
'immutable_files': '不可变文件检查',
|
||||
'suspicious_env_vars': '环境变量检查',
|
||||
};
|
||||
return names[category] || category;
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const d = dayjs.duration(seconds, 'seconds');
|
||||
const days = Math.floor(d.asDays());
|
||||
const hours = d.hours();
|
||||
const minutes = d.minutes();
|
||||
return `${days}天 ${hours}小时 ${minutes}分钟`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 系统信息 */}
|
||||
<Card
|
||||
title={<span className="font-semibold">系统信息</span>}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Descriptions column={{xs: 1, sm: 2}} bordered>
|
||||
<Descriptions.Item label="主机名">
|
||||
{result.systemInfo.hostname}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="操作系统">
|
||||
{result.systemInfo.os}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="内核版本">
|
||||
{result.systemInfo.kernelVersion}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运行时长">
|
||||
{formatUptime(result.systemInfo.uptime)}
|
||||
</Descriptions.Item>
|
||||
{result.systemInfo.publicIP && (
|
||||
<Descriptions.Item label="公网IP" span={2}>
|
||||
{result.systemInfo.publicIP}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="审计时间" span={2}>
|
||||
{dayjs(result.startTime).format('YYYY-MM-DD HH:mm:ss')} - {dayjs(result.endTime).format('HH:mm:ss')}
|
||||
<span className="ml-2 text-gray-500">
|
||||
(耗时: {((result.endTime - result.startTime) / 1000).toFixed(2)}秒)
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 安全检查结果 */}
|
||||
<Card
|
||||
title={<span className="font-semibold">安全检查结果</span>}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Collapse accordion>
|
||||
{result.securityChecks.map((check, index) => (
|
||||
<Panel
|
||||
key={index}
|
||||
header={
|
||||
<Space>
|
||||
{getStatusIcon(check.status)}
|
||||
<span className="font-medium">
|
||||
{getCategoryName(check.category)}
|
||||
</span>
|
||||
{getStatusTag(check.status)}
|
||||
<span className="text-gray-500 text-sm">
|
||||
{check.message}
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{check.details && check.details.length > 0 && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={check.details}
|
||||
renderItem={(detail) => (
|
||||
<List.Item className="flex-col items-start">
|
||||
<Space className="w-full">
|
||||
{getStatusIcon(detail.status)}
|
||||
<span className="font-mono text-sm">
|
||||
{detail.name}
|
||||
</span>
|
||||
{getStatusTag(detail.status)}
|
||||
<span className="text-gray-600 flex-1">
|
||||
{detail.message}
|
||||
</span>
|
||||
</Space>
|
||||
{detail.evidence && (
|
||||
<div className="ml-6 mt-2 p-3 bg-gray-50 rounded text-xs w-full">
|
||||
<div className="font-semibold mb-2 flex items-center gap-2">
|
||||
<FileText size={14}/>
|
||||
证据信息
|
||||
{detail.evidence.riskLevel && (
|
||||
<Tag
|
||||
color={
|
||||
detail.evidence.riskLevel === 'high' ? 'error' :
|
||||
detail.evidence.riskLevel === 'medium' ? 'warning' : 'default'
|
||||
}
|
||||
className="ml-2"
|
||||
>
|
||||
{detail.evidence.riskLevel === 'high' ? '高危' :
|
||||
detail.evidence.riskLevel === 'medium' ? '中危' : '低危'}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{detail.evidence.filePath && (
|
||||
<div className="mb-1">
|
||||
<span className="font-medium">文件路径: </span>
|
||||
<code className="bg-white px-1">{detail.evidence.filePath}</code>
|
||||
</div>
|
||||
)}
|
||||
{detail.evidence.fileHash && (
|
||||
<div className="mb-1 flex items-start gap-1">
|
||||
<Hash size={12} className="mt-0.5"/>
|
||||
<span className="font-medium">SHA256: </span>
|
||||
<code className="bg-white px-1 break-all">{detail.evidence.fileHash}</code>
|
||||
</div>
|
||||
)}
|
||||
{detail.evidence.timestamp && (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<Clock size={12}/>
|
||||
<span className="font-medium">时间: </span>
|
||||
{dayjs(detail.evidence.timestamp).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
)}
|
||||
{detail.evidence.networkConn && (
|
||||
<div className="mb-1">
|
||||
<span className="font-medium">网络连接: </span>
|
||||
<code className="bg-white px-1">{detail.evidence.networkConn}</code>
|
||||
</div>
|
||||
)}
|
||||
{detail.evidence.processTree && detail.evidence.processTree.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium mb-1 flex items-center gap-1">
|
||||
<GitBranch size={12}/>
|
||||
进程树:
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded font-mono text-xs overflow-x-auto">
|
||||
{detail.evidence.processTree.map((line, idx) => (
|
||||
<div key={idx} className="whitespace-nowrap">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
{/* 修复建议 */}
|
||||
{result.recommendations && result.recommendations.length > 0 && (
|
||||
<Card
|
||||
title={<span className="font-semibold">修复建议</span>}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<List
|
||||
dataSource={result.recommendations}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item>
|
||||
<Space align="start">
|
||||
<span className="font-semibold text-gray-500">{index + 1}.</span>
|
||||
<span className={
|
||||
item.startsWith('【紧急】') ? 'text-red-600 font-medium' :
|
||||
item.startsWith('【警告】') ? 'text-yellow-600' :
|
||||
'text-gray-700'
|
||||
}>
|
||||
{item}
|
||||
</span>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditResultView;
|
||||
@@ -97,15 +97,6 @@ const MonitorList = () => {
|
||||
|
||||
const monitorSummaries = monitors;
|
||||
|
||||
const lastUpdatedDisplay =
|
||||
dataUpdatedAt && dataUpdatedAt > 0
|
||||
? new Date(dataUpdatedAt).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
: '尚未刷新';
|
||||
|
||||
const renderGridView = () => (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{monitorSummaries.map((stats) => {
|
||||
@@ -221,100 +212,6 @@ const MonitorList = () => {
|
||||
|
||||
const renderListView = () => (
|
||||
<>
|
||||
{/* 移动端:使用卡片式布局 */}
|
||||
<div className="flex flex-col gap-4 lg:hidden">
|
||||
{monitorSummaries.map((stats) => {
|
||||
const hasCert = stats.certExpiryDate > 0;
|
||||
const certExpiringSoon = hasCert && stats.certExpiryDays < 30;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stats.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/monitors/${encodeURIComponent(stats.id)}`)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigate(`/monitors/${encodeURIComponent(stats.id)}`);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer rounded-xl border border-slate-200 bg-white p-4 transition hover:border-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-300"
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-base font-semibold text-slate-900">
|
||||
{stats.name}
|
||||
</span>
|
||||
<StatusBadge status={stats.lastCheckStatus}/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500 break-all">
|
||||
{stats.showTargetPublic ? stats.target : '***'}
|
||||
</p>
|
||||
{stats.agentCount > 1 && (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{stats.agentCount} 个探针
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<span className="text-xs font-medium text-slate-600">当前响应</span>
|
||||
<span
|
||||
className="text-sm font-bold text-slate-900">{formatTime(stats.currentResponse)}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<span className="text-xs font-medium text-slate-600">24h 平均响应</span>
|
||||
<span
|
||||
className="text-sm font-bold text-slate-900">{formatTime(stats.avgResponse24h)}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-600">24h 在线率</span>
|
||||
<span
|
||||
className="text-xs font-bold text-slate-900">{formatPercentValue(stats.uptime24h)}%</span>
|
||||
</div>
|
||||
<UptimeBar uptime={stats.uptime24h}/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-600">30d 在线率</span>
|
||||
<span
|
||||
className="text-xs font-bold text-slate-900">{formatPercentValue(stats.uptime30d)}%</span>
|
||||
</div>
|
||||
<UptimeBar uptime={stats.uptime30d}/>
|
||||
</div>
|
||||
{hasCert && (
|
||||
<div
|
||||
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield
|
||||
className={`h-4 w-4 ${certExpiringSoon ? 'text-yellow-600' : 'text-slate-600'}`}/>
|
||||
<span className="text-xs font-medium text-slate-600">证书到期</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div
|
||||
className={`text-xs font-medium ${certExpiringSoon ? 'text-yellow-700' : 'text-slate-700'}`}>
|
||||
{formatDate(stats.certExpiryDate)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${certExpiringSoon ? 'text-yellow-600' : 'text-slate-500'}`}>
|
||||
剩余 {stats.certExpiryDays} 天
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 桌面端:使用表格布局 */}
|
||||
<div className="hidden overflow-hidden rounded-xl border border-slate-200 bg-white lg:block">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
@@ -435,8 +332,6 @@ const MonitorList = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-slate-900 flex flex-col">
|
||||
<PublicHeader
|
||||
title="服务监控"
|
||||
lastUpdated={lastUpdatedDisplay}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
showViewToggle={true}
|
||||
@@ -454,8 +349,6 @@ const MonitorList = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-slate-900 flex flex-col">
|
||||
<PublicHeader
|
||||
title="服务监控"
|
||||
lastUpdated={lastUpdatedDisplay}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
showViewToggle={true}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import React, {type ReactNode} from 'react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import React, {type ReactNode, useEffect, useMemo, useState} from 'react';
|
||||
import {useNavigate, useParams} from 'react-router-dom';
|
||||
import {Activity, ArrowLeft, Cpu, HardDrive, Loader2, MemoryStick, Network, Server, Box, Thermometer, Zap} from 'lucide-react';
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
MemoryStick,
|
||||
Network,
|
||||
Server,
|
||||
Thermometer,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
import type {TooltipProps} from 'recharts';
|
||||
import {
|
||||
Area,
|
||||
@@ -15,15 +25,22 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {getAgent, getAgentLatestMetrics, getAgentMetrics, type GetAgentMetricsRequest, getNetworkMetricsByInterface} from '../../api/agent';
|
||||
import {
|
||||
getAgent,
|
||||
getAgentLatestMetrics,
|
||||
getAgentMetrics,
|
||||
type GetAgentMetricsRequest,
|
||||
getNetworkMetricsByInterface,
|
||||
type NetworkMetricByInterface
|
||||
} from '../../api/agent';
|
||||
import type {
|
||||
Agent,
|
||||
AggregatedCPUMetric,
|
||||
AggregatedDiskIOMetric,
|
||||
AggregatedDiskMetric,
|
||||
AggregatedGPUMetric,
|
||||
AggregatedMemoryMetric,
|
||||
AggregatedNetworkMetric,
|
||||
AggregatedDiskMetric,
|
||||
AggregatedDiskIOMetric,
|
||||
AggregatedGPUMetric,
|
||||
AggregatedTemperatureMetric,
|
||||
LatestMetrics
|
||||
} from '../../types';
|
||||
@@ -340,7 +357,7 @@ const useAggregatedMetrics = (agentId: string | undefined, range: TimeRange) =>
|
||||
|
||||
// 用于获取按网卡分组的网络指标
|
||||
const useNetworkMetricsByInterface = (agentId: string | undefined, range: TimeRange) => {
|
||||
const [networkMetrics, setNetworkMetrics] = useState<AggregatedNetworkMetric[]>([]);
|
||||
const [networkMetrics, setNetworkMetrics] = useState<NetworkMetricByInterface[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
@@ -577,7 +594,12 @@ const ServerDetail = () => {
|
||||
|
||||
// GPU 图表数据(汇总所有GPU的平均利用率)
|
||||
const gpuChartData = useMemo(() => {
|
||||
const aggregated: Record<string, { time: string; utilization: number; temperature: number; count: number }> = {};
|
||||
const aggregated: Record<string, {
|
||||
time: string;
|
||||
utilization: number;
|
||||
temperature: number;
|
||||
count: number
|
||||
}> = {};
|
||||
|
||||
metricsData.gpu.forEach((item) => {
|
||||
const time = new Date(item.timestamp).toLocaleTimeString('zh-CN', {
|
||||
@@ -775,8 +797,10 @@ const ServerDetail = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<div className="mx-auto flex min-h-screen max-w-7xl flex-col px-4 pb-10 pt-6 sm:px-6 lg:px-8">
|
||||
<section className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-slate-900 via-slate-800 to-blue-900 p-6 text-white shadow-xl">
|
||||
<div className="absolute inset-0 opacity-30 [background-image:radial-gradient(circle_at_top,rgba(255,255,255,0.35),transparent_55%)]"/>
|
||||
<section
|
||||
className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-slate-900 via-slate-800 to-blue-900 p-6 text-white shadow-xl">
|
||||
<div
|
||||
className="absolute inset-0 opacity-30 [background-image:radial-gradient(circle_at_top,rgba(255,255,255,0.35),transparent_55%)]"/>
|
||||
<div className="relative flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-4">
|
||||
@@ -789,7 +813,8 @@ const ServerDetail = () => {
|
||||
返回概览
|
||||
</button>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 text-white">
|
||||
<div
|
||||
className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 text-white">
|
||||
<Server className="h-7 w-7"/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1209,7 +1234,8 @@ const ServerDetail = () => {
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-600">
|
||||
<span
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-600">
|
||||
<Zap className="h-4 w-4"/>
|
||||
</span>
|
||||
<div>
|
||||
@@ -1224,7 +1250,8 @@ const ServerDetail = () => {
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500">温度</span>
|
||||
<span className="font-medium text-slate-900">{gpu.temperature.toFixed(1)}°C</span>
|
||||
<span
|
||||
className="font-medium text-slate-900">{gpu.temperature.toFixed(1)}°C</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500">显存</span>
|
||||
@@ -1234,11 +1261,13 @@ const ServerDetail = () => {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500">功耗</span>
|
||||
<span className="font-medium text-slate-900">{gpu.powerDraw.toFixed(1)}W</span>
|
||||
<span
|
||||
className="font-medium text-slate-900">{gpu.powerDraw.toFixed(1)}W</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500">风扇转速</span>
|
||||
<span className="font-medium text-slate-900">{gpu.fanSpeed.toFixed(0)}%</span>
|
||||
<span
|
||||
className="font-medium text-slate-900">{gpu.fanSpeed.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user