feat(security): 服务器安全日志页面(统计卡片+TOP攻击源+VxeTable列表)
This commit is contained in:
parent
8b86b73424
commit
e069a823b3
@ -1,6 +1,7 @@
|
||||
# admin.xpcool.com 变更记录
|
||||
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
||||
|
||||
2026-08-27 | CHG | 服务器安全日志页(挂在系统监控菜单下,id=96):views/monitor/security/index.vue——顶部 4 统计卡片(SSH爆破/fail2ban封禁/成功登录/总数)+ TOP 攻击源标签区 + 筛选(来源IP/事件类型/时间范围 t-date-range-picker)+ Vben VxeTable 列表(时间/来源IP/来源端口/目标端口/事件类型标签/详情 tooltip,事件标签按类型配色);api/serverSecurity.ts(list/stats 全 POST,无 URL 参数);typecheck 通过
|
||||
2026-08-27 | CHG | 全量表格重构:views 下全部 11 个 t-table 页面统一改为 Vben Vxe Table(useVbenVxeGrid,#/adapter/vxe-table)+ Vben TableAction 行操作——system/{log(范式),login-log,admin,role}、recruitment/{info,crawler,push}、house/{data,listing,community,presale}、dashboard/workbench(两处本地小表 proxyConfig.enabled=false + gridApi.setGridOptions 写数据)。要点:gridOptions 需注解 VxeTableGridOptions(否则 type:'seq' 推断 string 报错);单元格自定义用 columns[].slots.default + h(TDesign组件);远程分页走 proxyConfig.ajax.query 返回 {items,total};筛选区保留手写 TDesign 组件不动;admin/role/listing/community 行操作 ActionItem 带 auth 权限码、删除 danger 标红;listing 多选批量标记改 vxe checkboxConfig + gridApi.grid.getCheckboxRecords();confirm 弹窗交互(删除/重置密码)保留 DialogPlugin 不变;typecheck 通过
|
||||
|
||||
2026-08-27 | FIX | 恢复丢失的前端代码:recovery 分支经 rebase/改名(dev↔main)折腾后工作树回退,未提交改动全丢;从悬空提交 f02a514(recovery 工作树快照,含 HEAD 缺失的全部文件)整体恢复 apps/web-tdesign/src——含 styles/theme.css 全局主题、dashboard/workbench 工作台页、system 五页卡片化美化、操作日志增强版(VxeTable)、house 预售/楼盘地图、recruitment 页面;另补 log 页卡片美化(wb-page-head+t-card)。已提交保护
|
||||
|
||||
78
apps/web-tdesign/src/api/serverSecurity.ts
Normal file
78
apps/web-tdesign/src/api/serverSecurity.ts
Normal file
@ -0,0 +1,78 @@
|
||||
// 服务器安全日志模块 API 客户端。
|
||||
// 接口规范:全部 POST;URL 不含参数(查询/路径参数均禁止);入参一律走 body。
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
// ---------- 数据类型 ----------
|
||||
export interface SecurityLogItem {
|
||||
id: number;
|
||||
logTime: string;
|
||||
srcIp: string;
|
||||
srcPort: number;
|
||||
destPort: number;
|
||||
eventType: string;
|
||||
eventName: string;
|
||||
detail: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SecurityLogListResp {
|
||||
list: SecurityLogItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SecurityTopIp {
|
||||
srcIp: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SecurityByType {
|
||||
eventType: string;
|
||||
eventName: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SecurityLogStatsResp {
|
||||
total: number;
|
||||
failed: number;
|
||||
banned: number;
|
||||
accepted: number;
|
||||
topIps: SecurityTopIp[];
|
||||
byType: SecurityByType[];
|
||||
}
|
||||
|
||||
// ---------- 列表 ----------
|
||||
export interface SecurityLogQuery {
|
||||
page: number;
|
||||
size: number;
|
||||
srcIp?: string;
|
||||
eventType?: string;
|
||||
destPort?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export function getSecurityLogList(data: SecurityLogQuery) {
|
||||
return requestClient.post<SecurityLogListResp>(
|
||||
'/api/service/admin/server-security/log/list',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 统计 ----------
|
||||
export function getSecurityLogStats(data: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}) {
|
||||
return requestClient.post<SecurityLogStatsResp>(
|
||||
'/api/service/admin/server-security/log/stats',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 事件类型选项(与后端 SecurityEventName 对应) ----------
|
||||
export const SECURITY_EVENT_OPTIONS = [
|
||||
{ label: 'SSH爆破尝试', value: 'failed_ssh' },
|
||||
{ label: 'SSH成功登录', value: 'accepted_ssh' },
|
||||
{ label: 'fail2ban封禁', value: 'banned' },
|
||||
{ label: 'fail2ban解封', value: 'unbanned' },
|
||||
];
|
||||
200
apps/web-tdesign/src/views/monitor/security/index.vue
Normal file
200
apps/web-tdesign/src/views/monitor/security/index.vue
Normal file
@ -0,0 +1,200 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SecurityLogStatsResp } from '#/api/serverSecurity';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getSecurityLogList,
|
||||
getSecurityLogStats,
|
||||
SECURITY_EVENT_OPTIONS,
|
||||
} from '#/api/serverSecurity';
|
||||
|
||||
// 服务器安全日志:展示宿主机采集上报的 SSH 爆破/登录/fail2ban 封禁记录
|
||||
const query = reactive({
|
||||
srcIp: '',
|
||||
eventType: '',
|
||||
dateRange: [] as string[],
|
||||
});
|
||||
|
||||
// 统计卡片(默认最近24小时)
|
||||
const stats = ref<SecurityLogStatsResp>({
|
||||
total: 0,
|
||||
failed: 0,
|
||||
banned: 0,
|
||||
accepted: 0,
|
||||
topIps: [],
|
||||
byType: [],
|
||||
});
|
||||
|
||||
/** 把日期数组转成后端时间范围(HH:MM:SS 补齐) */
|
||||
function rangeToParam(): { dateFrom?: string; dateTo?: string } {
|
||||
if (
|
||||
query.dateRange.length === 2 &&
|
||||
query.dateRange[0] &&
|
||||
query.dateRange[1]
|
||||
) {
|
||||
return {
|
||||
dateFrom: `${query.dateRange[0]} 00:00:00`,
|
||||
dateTo: `${query.dateRange[1]} 23:59:59`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats.value = (await getSecurityLogStats(
|
||||
rangeToParam(),
|
||||
)) as SecurityLogStatsResp;
|
||||
} catch {
|
||||
// 统计失败不阻塞列表
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions = {
|
||||
columns: [
|
||||
{ type: 'seq', width: 50, title: '#' },
|
||||
{ field: 'logTime', title: '时间', width: 165 },
|
||||
{ field: 'srcIp', title: '来源IP', width: 140 },
|
||||
{ field: 'srcPort', title: '来源端口', width: 100 },
|
||||
{ field: 'destPort', title: '目标端口', width: 100 },
|
||||
{
|
||||
field: 'eventName',
|
||||
title: '事件类型',
|
||||
width: 130,
|
||||
slots: { default: 'event_type' },
|
||||
},
|
||||
{
|
||||
field: 'detail',
|
||||
title: '日志详情',
|
||||
minWidth: 320,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
],
|
||||
pagerConfig: { pageSize: 20 },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
const data = await getSecurityLogList({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
srcIp: query.srcIp || undefined,
|
||||
eventType: query.eventType || undefined,
|
||||
...rangeToParam(),
|
||||
});
|
||||
return { items: data.list ?? [], total: data.total ?? 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
function search() {
|
||||
loadStats();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.srcIp = '';
|
||||
query.eventType = '';
|
||||
query.dateRange = [];
|
||||
loadStats();
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
onMounted(loadStats);
|
||||
|
||||
// 事件类型徽标配色(爆破红/封禁橙/登录绿/解封蓝)
|
||||
const typeColor: Record<string, string> = {
|
||||
failed_ssh: 'danger',
|
||||
banned: 'warning',
|
||||
accepted_ssh: 'success',
|
||||
unbanned: 'primary',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="mb-3 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<t-card :bordered="false" theme="danger">
|
||||
<div class="text-sm text-gray-500">SSH爆破尝试</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.failed }}</div>
|
||||
</t-card>
|
||||
<t-card :bordered="false" theme="warning">
|
||||
<div class="text-sm text-gray-500">fail2ban封禁</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.banned }}</div>
|
||||
</t-card>
|
||||
<t-card :bordered="false" theme="success">
|
||||
<div class="text-sm text-gray-500">SSH成功登录</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.accepted }}</div>
|
||||
</t-card>
|
||||
<t-card :bordered="false">
|
||||
<div class="text-sm text-gray-500">日志总数</div>
|
||||
<div class="mt-1 text-2xl font-bold">{{ stats.total }}</div>
|
||||
</t-card>
|
||||
</div>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<t-input
|
||||
v-model="query.srcIp"
|
||||
clearable
|
||||
placeholder="来源IP"
|
||||
style="width: 160px"
|
||||
@enter="search"
|
||||
/>
|
||||
<t-select
|
||||
v-model="query.eventType"
|
||||
clearable
|
||||
:options="SECURITY_EVENT_OPTIONS"
|
||||
placeholder="事件类型"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<t-date-range-picker
|
||||
v-model="query.dateRange"
|
||||
clearable
|
||||
placeholder="发生时间范围"
|
||||
style="width: 260px"
|
||||
/>
|
||||
<t-button theme="primary" @click="search">查询</t-button>
|
||||
<t-button variant="outline" @click="reset">重置</t-button>
|
||||
</div>
|
||||
|
||||
<!-- TOP 攻击源 -->
|
||||
<t-card v-if="stats.topIps.length > 0" :bordered="false" class="mb-3">
|
||||
<template #header>
|
||||
<span class="font-medium">TOP 攻击来源(爆破+封禁)</span>
|
||||
</template>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div
|
||||
v-for="(ip, idx) in stats.topIps.slice(0, 8)"
|
||||
:key="ip.srcIp"
|
||||
class="flex items-center gap-2 rounded border border-gray-200 px-3 py-1.5 dark:border-gray-600"
|
||||
>
|
||||
<span class="text-gray-400">#{{ idx + 1 }}</span>
|
||||
<span class="font-mono">{{ ip.srcIp }}</span>
|
||||
<t-tag theme="danger" variant="light">{{ ip.count }}次</t-tag>
|
||||
</div>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<Grid>
|
||||
<template #event_type="{ row }">
|
||||
<t-tag :theme="typeColor[row.eventType] || 'default'" variant="light">
|
||||
{{ row.eventName }}
|
||||
</t-tag>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
Loading…
Reference in New Issue
Block a user