日志管理

This commit is contained in:
zxr
2026-03-30 17:44:55 +08:00
parent a96208d5c8
commit e7ae4feef5
24 changed files with 3323 additions and 904 deletions

View File

@@ -10,6 +10,19 @@ export const fetchAlertLevelList = (data?: {
return request.get("/Alert/v1/severity/list", { params: data || {} });
};
/** 获取告警级别下拉选项(不分页),默认仅返回 enabled=true */
export const fetchAlertLevelOptions = (data?: {
keyword?: string;
enabled?: 'true' | 'false';
}) => {
return request.get("/Alert/v1/severity/options", {
params: {
keyword: data?.keyword || undefined,
enabled: data?.enabled ?? 'true',
},
})
}
/** 获取告警级别详情 */
export const fetchAlertLevelDetail = (id: number) => {
return request.get(`/Alert/v1/severity/get/${id}`);

View File

@@ -112,6 +112,18 @@ export const fetchSeverityList = (data: {
enabled?: string;
}) => request.get("/Alert/v1/severity/list", { params: data });
/** 获取告警级别下拉选项(不分页),支持 keyword 模糊搜索与 enabled=true 过滤 */
export const fetchSeverityOptions = (data?: {
keyword?: string;
enabled?: 'true' | 'false';
}) =>
request.get("/Alert/v1/severity/options", {
params: {
keyword: data?.keyword || undefined,
enabled: data?.enabled ?? 'true',
},
});
/** 获取工单模板下拉选项 */
export const fetchFeedbackTemplateOptions = (data?: {
status?: 'active' | 'inactive'

View File

@@ -64,6 +64,12 @@ export interface AlertSeverity {
name: string
color?: string
level?: number
description?: string
icon?: string
priority?: number
enabled?: boolean
is_default?: boolean
config?: any
}
// 指标元数据
@@ -188,6 +194,19 @@ export const fetchSeverityList = () => {
return request.get('/Alert/v1/severity/list')
}
/** 获取告警级别下拉选项(不分页),支持 keyword 模糊搜索与 enabled=true 过滤 */
export const fetchSeverityOptions = (params?: {
keyword?: string
enabled?: 'true' | 'false'
}) => {
return request.get('/Alert/v1/severity/options', {
params: {
keyword: params?.keyword || undefined,
enabled: params?.enabled ?? 'true',
},
})
}
/** 按 code 获取告警级别 */
export const fetchSeverityByCode = (code: string) => {
return request.get(`/Alert/v1/severity/get-by-code/${code}`)

157
src/api/ops/logs.ts Normal file
View File

@@ -0,0 +1,157 @@
import { request } from '@/api/request'
const BASE = '/Logs/v1'
/** 解析 bsm-sdk 风格响应,取出业务 data */
export function unwrapLogsPayload(res: any): any {
if (res == null) return null
if (typeof res.code === 'number' && res.code !== 0) return null
return res.details ?? res.data ?? null
}
export interface LogEvent {
id: number
created_at: string
source_kind: string
remote_addr: string
raw_payload: string
normalized_summary: string
normalized_detail: string
device_name: string
severity_code: string
trap_oid: string
alert_sent: boolean
}
export interface LogEntriesParams {
page?: number
page_size?: number
source_kind?: string
}
export interface LogEntriesResult {
total: number
page: number
page_size: number
items: LogEvent[]
}
export interface SyslogRule {
id?: number
created_at?: string
updated_at?: string
name: string
enabled: boolean
priority: number
device_name_contains: string
keyword_regex: string
alert_name: string
severity_code: string
policy_id: number
}
export interface TrapRule {
id?: number
created_at?: string
updated_at?: string
name: string
enabled: boolean
priority: number
oid_prefix: string
varbind_match_regex: string
alert_name: string
severity_code: string
policy_id: number
}
export interface TrapDictionaryEntry {
id?: number
created_at?: string
updated_at?: string
oid_prefix: string
title: string
description: string
severity_code: string
recovery_message: string
enabled: boolean
}
export interface TrapShield {
id?: number
created_at?: string
updated_at?: string
name: string
enabled: boolean
source_ip_cidr: string
oid_prefix: string
interface_hint: string
time_windows_json: string
}
export function fetchLogEntries(params?: LogEntriesParams) {
return request.get(`${BASE}/entries`, { params })
}
export function fetchSyslogRules() {
return request.get(`${BASE}/syslog-rules`)
}
export function createSyslogRule(data: Partial<SyslogRule>) {
return request.post(`${BASE}/syslog-rules`, data)
}
export function updateSyslogRule(id: number, data: Partial<SyslogRule>) {
return request.put(`${BASE}/syslog-rules/${id}`, data)
}
export function deleteSyslogRule(id: number) {
return request.delete(`${BASE}/syslog-rules/${id}`)
}
export function fetchTrapRules() {
return request.get(`${BASE}/trap-rules`)
}
export function createTrapRule(data: Partial<TrapRule>) {
return request.post(`${BASE}/trap-rules`, data)
}
export function updateTrapRule(id: number, data: Partial<TrapRule>) {
return request.put(`${BASE}/trap-rules/${id}`, data)
}
export function deleteTrapRule(id: number) {
return request.delete(`${BASE}/trap-rules/${id}`)
}
export function fetchTrapDictionary() {
return request.get(`${BASE}/trap-dictionary`)
}
export function createTrapDictionary(data: Partial<TrapDictionaryEntry>) {
return request.post(`${BASE}/trap-dictionary`, data)
}
export function updateTrapDictionary(id: number, data: Partial<TrapDictionaryEntry>) {
return request.put(`${BASE}/trap-dictionary/${id}`, data)
}
export function deleteTrapDictionary(id: number) {
return request.delete(`${BASE}/trap-dictionary/${id}`)
}
export function fetchTrapSuppressions() {
return request.get(`${BASE}/trap-suppressions`)
}
export function createTrapSuppression(data: Partial<TrapShield>) {
return request.post(`${BASE}/trap-suppressions`, data)
}
export function updateTrapSuppression(id: number, data: Partial<TrapShield>) {
return request.put(`${BASE}/trap-suppressions/${id}`, data)
}
export function deleteTrapSuppression(id: number) {
return request.delete(`${BASE}/trap-suppressions/${id}`)
}

View File

@@ -186,11 +186,12 @@ export const localMenuFlatItems: MenuItem[] = [
title: '日志监控',
title_en: 'Log Monitoring',
code: 'ops:综合监控:日志监控',
description: '综合监控 - 日志监控',
description: '综合监控 - 日志监控(对接 Logs',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/log',
menu_icon: 'appstore',
component: 'ops/pages/monitor/log',
type: 1,
sort_key: 13,
created_at: '2025-12-26T13:23:51.907711+08:00',
@@ -300,6 +301,91 @@ export const localMenuFlatItems: MenuItem[] = [
sort_key: 20,
created_at: '2025-12-26T13:23:51.828777+08:00',
},
{
id: 12010,
identity: '019c7000-0001-7000-8000-000000000001',
title: '日志采集',
title_en: 'Log Ingest',
code: 'ops:日志采集',
description: 'Syslog / SNMP TrapLogs 服务)',
app_id: 2,
menu_path: '/log-mgmt/',
menu_icon: 'File',
type: 1,
sort_key: 21,
created_at: '2026-03-30T10:00:00+08:00',
},
{
id: 12011,
identity: '019c7000-0001-7000-8000-000000000011',
title: '日志查询',
title_en: 'Log Query',
code: 'ops:日志采集:日志查询',
app_id: 2,
parent_id: 12010,
menu_path: '/log-mgmt/entries',
menu_icon: 'List',
component: 'ops/pages/log-mgmt/entries',
type: 1,
sort_key: 22,
created_at: '2026-03-30T10:00:00+08:00',
},
{
id: 12012,
identity: '019c7000-0001-7000-8000-000000000012',
title: 'Syslog 规则',
title_en: 'Syslog Rules',
code: 'ops:日志采集:syslog规则',
app_id: 2,
parent_id: 12010,
menu_path: '/log-mgmt/syslog-rules',
component: 'ops/pages/log-mgmt/syslog-rules',
type: 1,
sort_key: 23,
created_at: '2026-03-30T10:00:00+08:00',
},
{
id: 12013,
identity: '019c7000-0001-7000-8000-000000000013',
title: 'Trap 规则',
title_en: 'Trap Rules',
code: 'ops:日志采集:trap规则',
app_id: 2,
parent_id: 12010,
menu_path: '/log-mgmt/trap-rules',
component: 'ops/pages/log-mgmt/trap-rules',
type: 1,
sort_key: 24,
created_at: '2026-03-30T10:00:00+08:00',
},
{
id: 12014,
identity: '019c7000-0001-7000-8000-000000000014',
title: 'Trap 字典',
title_en: 'Trap Dictionary',
code: 'ops:日志采集:trap字典',
app_id: 2,
parent_id: 12010,
menu_path: '/log-mgmt/trap-dictionary',
component: 'ops/pages/log-mgmt/trap-dictionary',
type: 1,
sort_key: 25,
created_at: '2026-03-30T10:00:00+08:00',
},
{
id: 12015,
identity: '019c7000-0001-7000-8000-000000000015',
title: 'Trap 屏蔽',
title_en: 'Trap Suppressions',
code: 'ops:日志采集:trap屏蔽',
app_id: 2,
parent_id: 12010,
menu_path: '/log-mgmt/trap-suppressions',
component: 'ops/pages/log-mgmt/trap-suppressions',
type: 1,
sort_key: 26,
created_at: '2026-03-30T10:00:00+08:00',
},
{
id: 35,
identity: '019b591d-021a-74a3-8092-c15b990f3c7e',
@@ -311,7 +397,7 @@ export const localMenuFlatItems: MenuItem[] = [
menu_path: '/netarch/',
menu_icon: 'Laptop',
type: 1,
sort_key: 21,
sort_key: 30,
created_at: '2025-12-26T13:23:51.969818+08:00',
},
{
@@ -899,10 +985,11 @@ export const localMenuFlatItems: MenuItem[] = [
title: '系统日志',
title_en: 'System Logs',
code: 'SystemSettingsSystemLogs',
description: '系统日志',
description: '系统日志(操作审计说明,演示见 index_bak',
app_id: 2,
parent_id: 78,
menu_path: '/system-settings/system-logs',
component: 'ops/pages/system-settings/system-logs',
type: 1,
sort_key: 59,
created_at: '2025-12-27T12:31:02.556301+08:00',

View File

@@ -199,11 +199,12 @@ export const localMenuItems: MenuItem[] = [
title: '日志监控',
title_en: 'Log Monitoring',
code: 'ops:综合监控:日志监控',
description: '综合监控 - 日志监控',
description: '综合监控 - 日志监控(对接 Logs同日志查询',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/log',
menu_icon: 'appstore',
component: 'ops/pages/monitor/log',
type: 1,
sort_key: 5,
created_at: '2025-12-26T13:23:51.907711+08:00',
@@ -323,6 +324,99 @@ export const localMenuItems: MenuItem[] = [
},
],
},
{
id: 12010,
identity: '019c7000-0001-7000-8000-000000000001',
title: '日志采集',
title_en: 'Log Ingest',
code: 'ops:日志采集',
description: 'Syslog / SNMP Trap 采集与规则Logs 服务)',
app_id: 2,
menu_path: '/log-mgmt/',
menu_icon: 'File',
type: 1,
sort_key: 5,
created_at: '2026-03-30T10:00:00+08:00',
children: [
{
id: 12011,
identity: '019c7000-0001-7000-8000-000000000011',
title: '日志查询',
title_en: 'Log Query',
code: 'ops:日志采集:日志查询',
app_id: 2,
parent_id: 12010,
menu_path: '/log-mgmt/entries',
menu_icon: 'List',
component: 'ops/pages/log-mgmt/entries',
type: 1,
sort_key: 1,
created_at: '2026-03-30T10:00:00+08:00',
children: [],
},
{
id: 12012,
identity: '019c7000-0001-7000-8000-000000000012',
title: 'Syslog 规则',
title_en: 'Syslog Rules',
code: 'ops:日志采集:syslog规则',
app_id: 2,
parent_id: 12010,
menu_path: '/log-mgmt/syslog-rules',
menu_icon: 'Code',
component: 'ops/pages/log-mgmt/syslog-rules',
type: 1,
sort_key: 2,
created_at: '2026-03-30T10:00:00+08:00',
children: [],
},
{
id: 12013,
identity: '019c7000-0001-7000-8000-000000000013',
title: 'Trap 规则',
title_en: 'Trap Rules',
code: 'ops:日志采集:trap规则',
app_id: 2,
parent_id: 12010,
menu_path: '/log-mgmt/trap-rules',
component: 'ops/pages/log-mgmt/trap-rules',
type: 1,
sort_key: 3,
created_at: '2026-03-30T10:00:00+08:00',
children: [],
},
{
id: 12014,
identity: '019c7000-0001-7000-8000-000000000014',
title: 'Trap 字典',
title_en: 'Trap Dictionary',
code: 'ops:日志采集:trap字典',
app_id: 2,
parent_id: 12010,
menu_path: '/log-mgmt/trap-dictionary',
component: 'ops/pages/log-mgmt/trap-dictionary',
type: 1,
sort_key: 4,
created_at: '2026-03-30T10:00:00+08:00',
children: [],
},
{
id: 12015,
identity: '019c7000-0001-7000-8000-000000000015',
title: 'Trap 屏蔽',
title_en: 'Trap Suppressions',
code: 'ops:日志采集:trap屏蔽',
app_id: 2,
parent_id: 12010,
menu_path: '/log-mgmt/trap-suppressions',
component: 'ops/pages/log-mgmt/trap-suppressions',
type: 1,
sort_key: 5,
created_at: '2026-03-30T10:00:00+08:00',
children: [],
},
],
},
{
id: 35,
identity: '019b591d-021a-74a3-8092-c15b990f3c7e',
@@ -968,10 +1062,11 @@ export const localMenuItems: MenuItem[] = [
title: '系统日志',
title_en: 'System Logs',
code: 'SystemSettingsSystemLogs',
description: '系统日志',
description: '系统日志(操作审计说明页,演示数据见 index_bak',
app_id: 2,
parent_id: 78,
menu_path: '/system-settings/system-logs',
component: 'ops/pages/system-settings/system-logs',
type: 1,
sort_key: 13,
created_at: '2025-12-27T12:31:02.556301+08:00',

View File

@@ -125,7 +125,7 @@ import { columns as columnsConfig } from './config/columns'
import {
fetchHistories,
} from '@/api/ops/alertHistory'
import { fetchAlertLevelList } from '@/api/ops/alertLevel'
import { fetchAlertLevelOptions } from '@/api/ops/alertLevel'
import HistoryDetailDialog from './components/HistoryDetailDialog.vue'
import HistoryProcessListDialog from './components/HistoryProcessListDialog.vue'
@@ -231,10 +231,11 @@ const currentProcessAlertRecordId = computed(() => currentProcessAlertRecord.val
// 加载告警级别列表
const loadSeverityOptions = async () => {
try {
const res = await fetchAlertLevelList({ page: 1, page_size: 1000, enabled: 'true' })
if (res.code === 0 && res.details?.data) {
severityOptions.value = res.details.data
}
const res: any = await fetchAlertLevelOptions({ enabled: 'true' })
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res
severityOptions.value = Array.isArray(list) ? list : []
} catch (error) {
console.error('获取告警级别列表失败:', error)
}

View File

@@ -92,6 +92,10 @@
placeholder="请选择告警级别(可多选)"
multiple
allow-clear
allow-search
:filter-option="false"
:loading="severityLoading"
@search="handleSeveritySearch"
>
<a-option
v-for="level in severityLevels"
@@ -197,7 +201,7 @@
import { ref, watch, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createNoticeChannel, updateNoticeChannel } from '@/api/ops/noticeChannel'
import { fetchAlertLevelList } from '@/api/ops/alertLevel'
import { fetchAlertLevelOptions } from '@/api/ops/alertLevel'
interface NoticeChannel {
id?: number
@@ -256,22 +260,59 @@ const form = ref({
// 是否为编辑模式
const isEdit = computed(() => !!props.channel?.id)
// 获取告警级别列表
const fetchSeverityLevels = async () => {
const severityLoading = ref(false)
const baseSeverityLevels = ref<any[]>([])
// 获取告警级别列表(支持 keyword 搜索)
const fetchSeverityLevels = async (keyword?: string) => {
severityLoading.value = true
try {
const res = await fetchAlertLevelList({
page: 1,
page_size: 100,
const res: any = await fetchAlertLevelOptions({
keyword: keyword || undefined,
enabled: 'true',
})
if (res.code === 0 && res.details?.data) {
severityLevels.value = res.details.data
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res
const fetchedOptions = Array.isArray(list) ? list : []
// keyword 为空时缓存“全量可用选项”
const isBaseLoad = !keyword || keyword.trim().length === 0
if (isBaseLoad) {
baseSeverityLevels.value = fetchedOptions
}
// 防止搜索后缩小 options 导致已选项标签无法展示
const selectedCodes = Array.isArray(form.value.severity_filter) ? form.value.severity_filter : []
const selectedOptions = baseSeverityLevels.value.filter((s) => selectedCodes.includes(s.code))
const mergedMap = new Map<string, any>()
;[...fetchedOptions, ...selectedOptions].forEach((item) => {
if (item?.code) {
mergedMap.set(item.code, item)
}
})
severityLevels.value = Array.from(mergedMap.values())
} catch (error) {
console.error('获取告警级别列表失败:', error)
} finally {
severityLoading.value = false
}
}
let severitySearchTimer: ReturnType<typeof setTimeout> | null = null
const handleSeveritySearch = (keyword: string) => {
if (severitySearchTimer) {
clearTimeout(severitySearchTimer)
}
severitySearchTimer = setTimeout(() => {
fetchSeverityLevels(keyword)
}, 300)
}
// 处理静默时间段变化
const handleQuietHoursChange = () => {
if (quietHoursEnabled.value) {

View File

@@ -164,7 +164,7 @@
import { ref, reactive, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue'
import { createPolicy, fetchTemplateList, fetchSeverityList, fetchFeedbackTemplateOptions } from '@/api/ops/alertPolicy'
import { createPolicy, fetchTemplateList, fetchSeverityOptions, fetchFeedbackTemplateOptions } from '@/api/ops/alertPolicy'
import { fetchUserList } from '@/api/ops/rbac2'
interface Props {
@@ -241,11 +241,12 @@ const loadTemplateOptions = async () => {
const loadSeverityList = async () => {
try {
const res = await fetchSeverityList({ page: 1, page_size: 100 })
if (res.code === 0 && res.details?.data) {
severityList.value = res.details.data
initDispatchRuleData()
}
const res: any = await fetchSeverityOptions({ enabled: 'true' })
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res
severityList.value = Array.isArray(list) ? list : []
initDispatchRuleData()
} catch (error) {
console.error('获取告警级别失败:', error)
}

View File

@@ -226,7 +226,7 @@ import {
fetchPolicyDetail,
updatePolicy,
fetchTemplateList,
fetchSeverityList,
fetchSeverityOptions,
fetchFeedbackTemplateOptions,
} from '@/api/ops/alertPolicy'
import { fetchUserList } from '@/api/ops/rbac2'
@@ -323,10 +323,11 @@ const loadTemplateOptions = async () => {
// 加载告警级别
const loadSeverityList = async () => {
try {
const res = await fetchSeverityList({ page: 1, page_size: 100 })
if (res.code === 0 && res.details?.data) {
severityList.value = res.details.data
}
const res: any = await fetchSeverityOptions({ enabled: 'true' })
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res
severityList.value = Array.isArray(list) ? list : []
} catch (error) {
console.error('获取告警级别失败:', error)
}

View File

@@ -59,6 +59,9 @@
v-model="formData.severity_id"
placeholder="请选择告警级别"
:loading="severityLoading"
allow-search
:filter-option="false"
@search="handleSeveritySearch"
>
<a-option
v-for="severity in severityOptions"
@@ -155,7 +158,7 @@ import {
fetchRuleDetail,
createRule,
updateRule,
fetchSeverityList,
fetchSeverityOptions,
} from '@/api/ops/alertPolicy'
// Props
@@ -245,15 +248,40 @@ const formRules = {
// 告警级别选项
const severityOptions = ref<any[]>([])
const baseSeverityOptions = ref<any[]>([])
// 加载告警级别列表
const loadSeverityOptions = async () => {
const loadSeverityOptions = async (keyword?: string) => {
severityLoading.value = true
try {
const res = await fetchSeverityList({ page: 1, page_size: 100 })
if (res.code === 0 && res.details?.data) {
severityOptions.value = res.details.data
const res: any = await fetchSeverityOptions({
keyword,
enabled: 'true',
})
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res
const fetchedOptions = Array.isArray(list) ? list : []
// 首次/keyword 为空时缓存“全量可用选项”
const isBaseLoad = !keyword || keyword.trim().length === 0
if (isBaseLoad) {
baseSeverityOptions.value = fetchedOptions
}
// 防止搜索后缩小 options 导致当前已选项标签无法展示
const selectedId = formData.severity_id
const selectedOptions = baseSeverityOptions.value.filter((s) => s?.id === selectedId)
const mergedMap = new Map<number, any>()
;[...fetchedOptions, ...selectedOptions].forEach((item) => {
if (typeof item?.id === 'number') {
mergedMap.set(item.id, item)
}
})
severityOptions.value = Array.from(mergedMap.values())
} catch (error) {
console.error('获取告警级别失败:', error)
} finally {
@@ -261,6 +289,17 @@ const loadSeverityOptions = async () => {
}
}
let severitySearchTimer: ReturnType<typeof setTimeout> | null = null
const handleSeveritySearch = (keyword: string) => {
if (severitySearchTimer) {
clearTimeout(severitySearchTimer)
}
severitySearchTimer = setTimeout(() => {
loadSeverityOptions(keyword)
}, 300)
}
// 规则类型变化处理
const handleRuleTypeChange = (value: string) => {
// 根据规则类型清空相关字段

View File

@@ -121,11 +121,7 @@
import { ref, reactive, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import {
fetchRuleList,
deleteRule,
fetchSeverityList,
} from '@/api/ops/alertPolicy'
import { fetchRuleList, deleteRule } from '@/api/ops/alertPolicy'
import RuleFormDialog from './RuleFormDialog.vue'
// Props

View File

@@ -169,7 +169,7 @@ import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import { columns } from './config/columns'
import { fetchAlertRecords } from '@/api/ops/alertRecord'
import { fetchAlertLevelList } from '@/api/ops/alertLevel'
import { fetchAlertLevelOptions } from '@/api/ops/alertLevel'
import AckDialog from './components/AckDialog.vue'
import ResolveDialog from './components/ResolveDialog.vue'
@@ -223,9 +223,11 @@ onMounted(async () => {
const loadSeverityOptions = async () => {
try {
const res = await fetchAlertLevelList({ page: 1, page_size: 100 })
const list = res.details?.data ?? (res as any).data ?? []
severityOptions.value = list.map((item: any) => ({
const res: any = await fetchAlertLevelOptions({ enabled: 'true' })
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res || []
severityOptions.value = (Array.isArray(list) ? list : []).map((item: any) => ({
label: item.name || item.code,
value: item.id,
}))

View File

@@ -55,8 +55,17 @@
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="告警级别" :field="`rules[${index}].severity_code`" :rules="getRuleFieldRules('severity_code')">
<a-select v-model="rule.severity_code" placeholder="请选择" @change="syncUpdate(index)">
<a-option v-for="item in severityOptions" :key="item.code" :value="item.code">
<a-select
v-model="rule.severity_code"
placeholder="请选择"
:loading="severityLoading"
allow-search
:filter-option="false"
@search="handleSeveritySearch"
@dropdown-visible-change="handleSeverityDropdownChange"
@change="syncUpdate(index)"
>
<a-option v-for="item in localSeverityOptions" :key="item.code" :value="item.code">
{{ item.name }}
</a-option>
</a-select>
@@ -125,6 +134,7 @@
import { ref, watch } from 'vue'
import type { FormInstance } from '@arco-design/web-vue'
import {
fetchSeverityOptions,
fetchMetricsMeta,
DATA_SOURCES,
COMPARE_OPERATORS,
@@ -155,6 +165,11 @@ const ruleFormRef = ref<FormInstance | null>(null)
// 本地规则数据(深拷贝)
const localRules = ref<RuleItem[]>([])
// 告警级别下拉选项(支持搜索后局部刷新)
const baseSeverityOptions = ref<AlertSeverity[]>([])
const localSeverityOptions = ref<AlertSeverity[]>([])
const severityLoading = ref(false)
// 监听 props 变化,深拷贝到本地
watch(
() => props.rules,
@@ -164,6 +179,70 @@ watch(
{ immediate: true, deep: true }
)
// 初始化/同步告警级别选项
watch(
() => props.severityOptions,
(val) => {
baseSeverityOptions.value = val || []
localSeverityOptions.value = val || []
},
{ immediate: true },
)
let severitySearchTimer: ReturnType<typeof setTimeout> | null = null
const loadSeverityOptions = async (keyword?: string) => {
severityLoading.value = true
try {
const res: any = await fetchSeverityOptions({
keyword: keyword || undefined,
enabled: 'true',
})
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res
const fetchedOptions = Array.isArray(list) ? list : []
// 合并当前已选值,防止搜索后 options 缩小导致已选项标签无法展示
const selectedCodes = new Set(
localRules.value
.map((r) => r?.severity_code)
.filter((v): v is string => typeof v === 'string' && v.length > 0),
)
const selectedOptions = baseSeverityOptions.value.filter((s) => selectedCodes.has(s.code))
const mergedMap = new Map<string, AlertSeverity>()
;[...fetchedOptions, ...selectedOptions].forEach((item) => {
if (item?.code) {
mergedMap.set(item.code, item)
}
})
localSeverityOptions.value = Array.from(mergedMap.values())
} catch (error) {
console.error('加载告警级别下拉选项失败:', error)
} finally {
severityLoading.value = false
}
}
const handleSeverityDropdownChange = (visible: boolean) => {
if (visible && localSeverityOptions.value.length === 0) {
loadSeverityOptions()
}
}
const handleSeveritySearch = (keyword: string) => {
if (severitySearchTimer) {
clearTimeout(severitySearchTimer)
}
severitySearchTimer = setTimeout(() => {
loadSeverityOptions(keyword)
}, 300)
}
// 动态生成验证规则
const getRuleFieldRules = (field: string) => {
const messages: Record<string, string> = {

View File

@@ -163,7 +163,7 @@ import {
fetchTemplateDetail,
fetchChannelList,
fetchSuppressionList,
fetchSeverityList,
fetchSeverityOptions,
fetchMetricsMeta,
TEMPLATE_CATEGORIES,
type AlertTemplate,
@@ -330,10 +330,12 @@ const loadMetricsForRule = async (index: number) => {
// 加载告警级别
const loadSeverityOptions = async () => {
try {
const res: any = await fetchSeverityList()
if (res.code === 0) {
severityOptions.value = res.details?.data || res.details?.list || []
}
const res: any = await fetchSeverityOptions({ enabled: 'true' })
// 兼容:后端可能返回数组,也可能是 { code, details: { data } }
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res
severityOptions.value = Array.isArray(list) ? list : []
} catch (error) {
console.error('加载告警级别失败:', error)
}

View File

@@ -0,0 +1,245 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="日志查询"
search-button-text="查询"
reset-button-text="重置"
:show-download="false"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@refresh="handleRefresh"
>
<template #source_kind="{ record }">
<a-tag>{{ sourceKindLabel(record.source_kind) }}</a-tag>
</template>
<template #raw_payload="{ record }">
<span class="ellipsis-cell" :title="record.raw_payload">{{ truncate(record.raw_payload, 80) }}</span>
</template>
<template #alert_sent="{ record }">
<a-tag :color="record.alert_sent ? 'green' : 'gray'">
{{ record.alert_sent ? '已转发' : '否' }}
</a-tag>
</template>
<template #operations="{ record }">
<a-button type="text" size="small" @click="openDetail(record)">详情</a-button>
</template>
</search-table>
<a-drawer
v-model:visible="detailVisible"
width="640px"
:title="detailRow ? `日志详情 #${detailRow.id}` : '日志详情'"
:footer="false"
unmount-on-close
>
<template v-if="detailRow">
<a-descriptions :column="1" bordered size="large">
<a-descriptions-item label="来源类型">
{{ sourceKindLabel(detailRow.source_kind) }}
</a-descriptions-item>
<a-descriptions-item label="采集时间">{{ detailRow.created_at }}</a-descriptions-item>
<a-descriptions-item label="来源地址">{{ detailRow.remote_addr || '-' }}</a-descriptions-item>
<a-descriptions-item label="设备名">{{ detailRow.device_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="严重级别">{{ detailRow.severity_code || '-' }}</a-descriptions-item>
<a-descriptions-item label="Trap OID">{{ detailRow.trap_oid || '-' }}</a-descriptions-item>
<a-descriptions-item label="已转发告警">
{{ detailRow.alert_sent ? '是' : '否' }}
</a-descriptions-item>
<a-descriptions-item label="摘要">{{ detailRow.normalized_summary || '-' }}</a-descriptions-item>
<a-descriptions-item label="详情">
<pre class="pre-block">{{ detailRow.normalized_detail || '-' }}</pre>
</a-descriptions-item>
<a-descriptions-item label="原始报文">
<pre class="pre-block">{{ detailRow.raw_payload || '-' }}</pre>
</a-descriptions-item>
</a-descriptions>
</template>
</a-drawer>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import {
fetchLogEntries,
unwrapLogsPayload,
type LogEvent,
} from '@/api/ops/logs'
const loading = ref(false)
const tableData = ref<LogEvent[]>([])
const formModel = ref({
source_kind: '' as string,
})
const detailVisible = ref(false)
const detailRow = ref<LogEvent | null>(null)
const pagination = reactive({
current: 1,
pageSize: 50,
total: 0,
})
const formItems = computed<FormItem[]>(() => [
{
field: 'source_kind',
label: '来源类型',
type: 'select',
placeholder: '全部',
span: 6,
options: [
{ label: '全部', value: '' },
{ label: 'Syslog', value: 'syslog' },
{ label: 'SNMP Trap', value: 'snmp_trap' },
],
},
])
const columns = computed<TableColumnData[]>(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{
title: '来源',
dataIndex: 'source_kind',
slotName: 'source_kind',
width: 110,
},
{ title: '时间', dataIndex: 'created_at', width: 180, ellipsis: true, tooltip: true },
{ title: '来源地址', dataIndex: 'remote_addr', width: 130, ellipsis: true, tooltip: true },
{ title: '设备', dataIndex: 'device_name', width: 120, ellipsis: true, tooltip: true },
{ title: '级别', dataIndex: 'severity_code', width: 90 },
{ title: 'OID', dataIndex: 'trap_oid', width: 140, ellipsis: true, tooltip: true },
{
title: '原始报文',
dataIndex: 'raw_payload',
slotName: 'raw_payload',
ellipsis: true,
tooltip: true,
},
{
title: '已告警',
dataIndex: 'alert_sent',
slotName: 'alert_sent',
width: 90,
},
{
title: '操作',
dataIndex: 'operations',
slotName: 'operations',
width: 88,
fixed: 'right',
},
])
function sourceKindLabel(k: string) {
if (k === 'syslog') return 'Syslog'
if (k === 'snmp_trap') return 'SNMP Trap'
return k || '-'
}
function truncate(s: string, n: number) {
if (!s) return '-'
return s.length <= n ? s : `${s.slice(0, n)}`
}
function handleFormModelUpdate(v: Record<string, unknown>) {
formModel.value = v as { source_kind: string }
}
async function fetchList() {
loading.value = true
try {
const res: any = await fetchLogEntries({
page: pagination.current,
page_size: pagination.pageSize,
source_kind: formModel.value.source_kind || undefined,
})
const payload = unwrapLogsPayload(res) ?? {}
tableData.value = payload.items ?? []
pagination.total = payload.total ?? 0
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '加载失败')
}
} catch (e: any) {
console.error(e)
Message.error(e?.message || '加载失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.current = 1
fetchList()
}
function handleReset() {
formModel.value = { source_kind: '' }
pagination.current = 1
fetchList()
}
function handlePageChange(c: number) {
pagination.current = c
fetchList()
}
function handlePageSizeChange(size: number) {
pagination.pageSize = size
pagination.current = 1
fetchList()
}
function handleRefresh() {
fetchList()
}
function openDetail(row: LogEvent) {
detailRow.value = row
detailVisible.value = true
}
fetchList()
</script>
<script lang="ts">
export default { name: 'LogMgmtEntries' }
</script>
<style scoped lang="less">
.container {
padding: 16px;
}
.pre-block {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.5;
max-height: 280px;
overflow: auto;
}
.ellipsis-cell {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,455 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="clientPagination"
title="Syslog 匹配规则"
search-button-text="查询"
reset-button-text="重置"
:show-download="false"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@refresh="fetchList"
>
<template #toolbar-left>
<a-button type="primary" @click="openCreate">
<template #icon><icon-plus /></template>
新建规则
</a-button>
</template>
<template #enabled="{ record }">
<a-tag :color="record.enabled ? 'green' : 'red'">{{ record.enabled ? '启用' : '禁用' }}</a-tag>
</template>
<template #operations="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">删除</a-button>
</a-space>
</template>
</search-table>
<a-modal
v-model:visible="modalVisible"
:title="editingId ? `编辑规则 #${editingId}` : '新建 Syslog 规则'"
width="640px"
:confirm-loading="submitting"
@ok="submitForm"
@cancel="modalVisible = false"
>
<a-form ref="formRef" :model="formData" layout="vertical">
<a-form-item field="name" label="规则名称" :rules="[{ required: true, message: '必填' }]">
<a-input v-model="formData.name" placeholder="规则名称" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="enabled" label="启用">
<a-switch v-model="formData.enabled" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="priority" label="优先级(越大越优先)">
<a-input-number v-model="formData.priority" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="device_name_contains" label="设备名包含">
<a-input v-model="formData.device_name_contains" placeholder="子串匹配" />
</a-form-item>
<a-form-item field="keyword_regex" label="关键字正则">
<a-input v-model="formData.keyword_regex" placeholder="正则表达式" />
</a-form-item>
<a-form-item field="alert_name" label="告警名称">
<a-input v-model="formData.alert_name" placeholder="匹配后告警标题" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="severity_code" label="严重级别代码">
<a-select
v-model="formData.severity_code"
placeholder="请选择告警级别"
:loading="severityLoading"
allow-search
:filter-option="false"
@search="handleSeveritySearch"
>
<a-option
v-for="item in severityOptions"
:key="item.code"
:value="item.code"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="policy_id" label="告警策略">
<a-select
v-model="formData.policy_id"
placeholder="请选择告警策略"
:loading="policyLoading"
allow-search
:filter-option="false"
@search="handlePolicySearch"
@change="handlePolicyChange"
>
<a-option
v-for="item in policyOptions"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import { fetchSeverityOptions, type AlertSeverity } from '@/api/ops/alertTemplate'
import { fetchPolicyList } from '@/api/ops/alertPolicy'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormInstance } from '@arco-design/web-vue'
import {
createSyslogRule,
deleteSyslogRule,
fetchSyslogRules,
unwrapLogsPayload,
updateSyslogRule,
type SyslogRule,
} from '@/api/ops/logs'
const loading = ref(false)
const tableData = ref<SyslogRule[]>([])
const allRows = ref<SyslogRule[]>([])
const formModel = ref({ keyword: '' })
function handleFormModelUpdate(v: Record<string, unknown>) {
formModel.value = v as { keyword: string }
}
const modalVisible = ref(false)
const submitting = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const emptyForm = (): SyslogRule => ({
name: '',
enabled: true,
priority: 0,
device_name_contains: '',
keyword_regex: '',
alert_name: '',
severity_code: '',
policy_id: 0,
})
const formData = reactive<SyslogRule>(emptyForm())
const severityOptions = ref<AlertSeverity[]>([])
const baseSeverityOptions = ref<AlertSeverity[]>([])
const severityLoading = ref(false)
let severitySearchTimer: ReturnType<typeof setTimeout> | null = null
type PolicyOption = { id: number; name: string }
const policyLoading = ref(false)
const policyOptions = ref<PolicyOption[]>([{ id: 0, name: '未绑定' }])
const basePolicyOptions = ref<PolicyOption[]>([{ id: 0, name: '未绑定' }])
let policySearchTimer: ReturnType<typeof setTimeout> | null = null
async function loadPolicyOptions(keyword?: string) {
policyLoading.value = true
try {
const res: any = await fetchPolicyList({
page: 1,
page_size: keyword ? 50 : 1000,
enabled: true,
keyword: keyword || undefined,
})
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data ?? res?.details?.list ?? res?.data?.data ?? res?.data ?? []
const fetchedOptions: PolicyOption[] = (Array.isArray(list) ? list : [])
.map((p: any) => ({ id: Number(p.id), name: p.name || '' }))
.filter((p: PolicyOption) => !Number.isNaN(p.id))
const isBaseLoad = !keyword || keyword.trim().length === 0
if (isBaseLoad) {
basePolicyOptions.value = [{ id: 0, name: '未绑定' }, ...fetchedOptions]
}
const selectedId = formData.policy_id
const selectedOptions = basePolicyOptions.value.filter((p) => p.id === selectedId)
const mergedMap = new Map<number, PolicyOption>()
;[...fetchedOptions, ...selectedOptions].forEach((item) => {
mergedMap.set(item.id, item)
})
mergedMap.set(0, { id: 0, name: '未绑定' })
policyOptions.value = Array.from(mergedMap.values())
} catch (e: any) {
console.error('加载告警策略失败:', e)
policyOptions.value = [{ id: 0, name: '未绑定' }]
basePolicyOptions.value = [{ id: 0, name: '未绑定' }]
} finally {
policyLoading.value = false
}
}
function handlePolicySearch(keyword: string) {
if (policySearchTimer) {
clearTimeout(policySearchTimer)
}
policySearchTimer = setTimeout(() => {
loadPolicyOptions(keyword)
}, 300)
}
function handlePolicyChange(v: any) {
formData.policy_id = Number(v)
}
async function loadSeverityOptions(keyword?: string) {
severityLoading.value = true
try {
const res: any = await fetchSeverityOptions({
keyword,
enabled: 'true',
})
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res
const fetchedOptions = Array.isArray(list) ? list : []
const isBaseLoad = !keyword || keyword.trim().length === 0
if (isBaseLoad) {
baseSeverityOptions.value = fetchedOptions
}
const selectedCode = formData.severity_code
const selectedOptions = baseSeverityOptions.value.filter((s) => s.code === selectedCode)
const mergedMap = new Map<string, AlertSeverity>()
;[...fetchedOptions, ...selectedOptions].forEach((item) => {
if (item?.code) mergedMap.set(item.code, item)
})
severityOptions.value = Array.from(mergedMap.values())
} catch (e: any) {
console.error('加载告警级别失败:', e)
severityOptions.value = []
} finally {
severityLoading.value = false
}
}
function handleSeveritySearch(keyword: string) {
if (severitySearchTimer) {
clearTimeout(severitySearchTimer)
}
severitySearchTimer = setTimeout(() => {
loadSeverityOptions(keyword)
}, 300)
}
const clientPagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '规则名 / 告警名',
span: 8,
},
])
const columns = computed<TableColumnData[]>(() => [
{ title: 'ID', dataIndex: 'id', width: 72 },
{ title: '名称', dataIndex: 'name', ellipsis: true, tooltip: true, width: 140 },
{ title: '优先级', dataIndex: 'priority', width: 80 },
{ title: '启用', dataIndex: 'enabled', slotName: 'enabled', width: 88 },
{ title: '设备名包含', dataIndex: 'device_name_contains', ellipsis: true, tooltip: true },
{ title: '关键字正则', dataIndex: 'keyword_regex', ellipsis: true, tooltip: true },
{ title: '告警名', dataIndex: 'alert_name', ellipsis: true, tooltip: true },
{ title: '级别', dataIndex: 'severity_code', width: 90 },
{ title: '策略ID', dataIndex: 'policy_id', width: 88 },
{ title: '操作', slotName: 'operations', width: 160, fixed: 'right' },
])
function applyFilter(list: SyslogRule[]) {
const kw = formModel.value.keyword?.trim()
if (!kw) return list
return list.filter(
r =>
r.name?.includes(kw) ||
r.alert_name?.includes(kw) ||
r.keyword_regex?.includes(kw)
)
}
function slicePage(list: SyslogRule[]) {
const start = (clientPagination.current - 1) * clientPagination.pageSize
tableData.value = list.slice(start, start + clientPagination.pageSize)
}
async function fetchList() {
loading.value = true
try {
const res: any = await fetchSyslogRules()
const payload = unwrapLogsPayload(res)
const items = (payload?.items ?? []) as SyslogRule[]
allRows.value = items
const filtered = applyFilter(items)
clientPagination.total = filtered.length
slicePage(filtered)
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '加载失败')
}
} catch (e: any) {
console.error(e)
Message.error(e?.message || '加载失败')
allRows.value = []
tableData.value = []
clientPagination.total = 0
} finally {
loading.value = false
}
}
function handleSearch() {
clientPagination.current = 1
const filtered = applyFilter(allRows.value)
clientPagination.total = filtered.length
slicePage(filtered)
}
function handleReset() {
formModel.value = { keyword: '' }
clientPagination.current = 1
const filtered = applyFilter(allRows.value)
clientPagination.total = filtered.length
slicePage(filtered)
}
function handlePageChange(c: number) {
clientPagination.current = c
slicePage(applyFilter(allRows.value))
}
function handlePageSizeChange(size: number) {
clientPagination.pageSize = size
clientPagination.current = 1
slicePage(applyFilter(allRows.value))
}
function openCreate() {
editingId.value = null
Object.assign(formData, emptyForm())
modalVisible.value = true
}
function openEdit(row: SyslogRule) {
if (!row.id) return
editingId.value = row.id
Object.assign(formData, {
name: row.name,
enabled: row.enabled,
priority: row.priority,
device_name_contains: row.device_name_contains ?? '',
keyword_regex: row.keyword_regex ?? '',
alert_name: row.alert_name ?? '',
severity_code: row.severity_code ?? '',
policy_id: row.policy_id ?? 0,
})
modalVisible.value = true
}
async function submitForm() {
const err = await formRef.value?.validate()
if (err) return
submitting.value = true
try {
if (editingId.value != null) {
const res: any = await updateSyslogRule(editingId.value, { ...formData })
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '保存失败')
return
}
Message.success('已保存')
} else {
const res: any = await createSyslogRule({ ...formData })
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '创建失败')
return
}
Message.success('已创建')
}
modalVisible.value = false
await fetchList()
} catch (e: any) {
Message.error(e?.message || '请求失败')
} finally {
submitting.value = false
}
}
function handleDelete(row: SyslogRule) {
if (!row.id) return
Modal.confirm({
title: '确认删除',
content: `删除规则「${row.name}」?`,
onOk: async () => {
try {
const res: any = await deleteSyslogRule(row.id!)
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '删除失败')
return
}
Message.success('已删除')
await fetchList()
} catch (e: any) {
Message.error(e?.message || '删除失败')
}
},
})
}
fetchList()
loadSeverityOptions()
loadPolicyOptions()
</script>
<script lang="ts">
export default { name: 'LogMgmtSyslogRules' }
</script>
<style scoped lang="less">
.container {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,353 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="clientPagination"
title="Trap 字典"
search-button-text="查询"
reset-button-text="重置"
:show-download="false"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@refresh="fetchList"
>
<template #toolbar-left>
<a-button type="primary" @click="openCreate">
<template #icon><icon-plus /></template>
新建条目
</a-button>
</template>
<template #enabled="{ record }">
<a-tag :color="record.enabled ? 'green' : 'red'">{{ record.enabled ? '启用' : '禁用' }}</a-tag>
</template>
<template #operations="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">删除</a-button>
</a-space>
</template>
</search-table>
<a-modal
v-model:visible="modalVisible"
:title="editingId ? `编辑字典 #${editingId}` : '新建 Trap 字典条目'"
width="640px"
:confirm-loading="submitting"
@ok="submitForm"
@cancel="modalVisible = false"
>
<a-form ref="formRef" :model="formData" layout="vertical">
<a-form-item
field="oid_prefix"
label="OID 前缀"
:rules="[{ required: true, message: '必填' }]"
>
<a-input v-model="formData.oid_prefix" placeholder="唯一前缀" />
</a-form-item>
<a-form-item field="title" label="标题" :rules="[{ required: true, message: '必填' }]">
<a-input v-model="formData.title" />
</a-form-item>
<a-form-item field="description" label="描述">
<a-textarea v-model="formData.description" :rows="3" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="severity_code" label="严重级别代码">
<a-select
v-model="formData.severity_code"
placeholder="请选择告警级别"
:loading="severityLoading"
allow-search
:filter-option="false"
@search="handleSeveritySearch"
>
<a-option
v-for="item in severityOptions"
:key="item.code"
:value="item.code"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="enabled" label="启用">
<a-switch v-model="formData.enabled" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="recovery_message" label="恢复说明">
<a-textarea v-model="formData.recovery_message" :rows="2" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import { fetchSeverityOptions, type AlertSeverity } from '@/api/ops/alertTemplate'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormInstance } from '@arco-design/web-vue'
import {
createTrapDictionary,
deleteTrapDictionary,
fetchTrapDictionary,
unwrapLogsPayload,
updateTrapDictionary,
type TrapDictionaryEntry,
} from '@/api/ops/logs'
const loading = ref(false)
const tableData = ref<TrapDictionaryEntry[]>([])
const allRows = ref<TrapDictionaryEntry[]>([])
const formModel = ref({ keyword: '' })
function handleFormModelUpdate(v: Record<string, unknown>) {
formModel.value = v as { keyword: string }
}
const modalVisible = ref(false)
const submitting = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const emptyForm = (): TrapDictionaryEntry => ({
oid_prefix: '',
title: '',
description: '',
severity_code: '',
recovery_message: '',
enabled: true,
})
const formData = reactive<TrapDictionaryEntry>(emptyForm())
const severityOptions = ref<AlertSeverity[]>([])
const baseSeverityOptions = ref<AlertSeverity[]>([])
const severityLoading = ref(false)
let severitySearchTimer: ReturnType<typeof setTimeout> | null = null
async function loadSeverityOptions(keyword?: string) {
severityLoading.value = true
try {
const res: any = await fetchSeverityOptions({
keyword,
enabled: 'true',
})
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res
const fetchedOptions = Array.isArray(list) ? list : []
const isBaseLoad = !keyword || keyword.trim().length === 0
if (isBaseLoad) {
baseSeverityOptions.value = fetchedOptions
}
const selectedCode = formData.severity_code
const selectedOptions = baseSeverityOptions.value.filter((s) => s.code === selectedCode)
const mergedMap = new Map<string, AlertSeverity>()
;[...fetchedOptions, ...selectedOptions].forEach((item) => {
if (item?.code) mergedMap.set(item.code, item)
})
severityOptions.value = Array.from(mergedMap.values())
} catch (e: any) {
console.error('加载告警级别失败:', e)
severityOptions.value = []
} finally {
severityLoading.value = false
}
}
function handleSeveritySearch(keyword: string) {
if (severitySearchTimer) {
clearTimeout(severitySearchTimer)
}
severitySearchTimer = setTimeout(() => {
loadSeverityOptions(keyword)
}, 300)
}
const clientPagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formItems = computed<FormItem[]>(() => [
{ field: 'keyword', label: '关键词', type: 'input', placeholder: 'OID / 标题', span: 8 },
])
const columns = computed<TableColumnData[]>(() => [
{ title: 'ID', dataIndex: 'id', width: 72 },
{ title: 'OID 前缀', dataIndex: 'oid_prefix', ellipsis: true, tooltip: true },
{ title: '标题', dataIndex: 'title', ellipsis: true, tooltip: true },
{ title: '级别', dataIndex: 'severity_code', width: 90 },
{ title: '启用', dataIndex: 'enabled', slotName: 'enabled', width: 88 },
{ title: '描述', dataIndex: 'description', ellipsis: true, tooltip: true },
{ title: '操作', slotName: 'operations', width: 160, fixed: 'right' },
])
function applyFilter(list: TrapDictionaryEntry[]) {
const kw = formModel.value.keyword?.trim()
if (!kw) return list
return list.filter(
r =>
r.oid_prefix?.includes(kw) ||
r.title?.includes(kw) ||
r.description?.includes(kw)
)
}
function slicePage(list: TrapDictionaryEntry[]) {
const start = (clientPagination.current - 1) * clientPagination.pageSize
tableData.value = list.slice(start, start + clientPagination.pageSize)
}
async function fetchList() {
loading.value = true
try {
const res: any = await fetchTrapDictionary()
const payload = unwrapLogsPayload(res)
const items = (payload?.items ?? []) as TrapDictionaryEntry[]
allRows.value = items
const filtered = applyFilter(items)
clientPagination.total = filtered.length
slicePage(filtered)
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '加载失败')
}
} catch (e: any) {
Message.error(e?.message || '加载失败')
allRows.value = []
tableData.value = []
clientPagination.total = 0
} finally {
loading.value = false
}
}
function handleSearch() {
clientPagination.current = 1
const filtered = applyFilter(allRows.value)
clientPagination.total = filtered.length
slicePage(filtered)
}
function handleReset() {
formModel.value = { keyword: '' }
clientPagination.current = 1
const filtered = applyFilter(allRows.value)
clientPagination.total = filtered.length
slicePage(filtered)
}
function handlePageChange(c: number) {
clientPagination.current = c
slicePage(applyFilter(allRows.value))
}
function handlePageSizeChange(size: number) {
clientPagination.pageSize = size
clientPagination.current = 1
slicePage(applyFilter(allRows.value))
}
function openCreate() {
editingId.value = null
Object.assign(formData, emptyForm())
modalVisible.value = true
}
function openEdit(row: TrapDictionaryEntry) {
if (!row.id) return
editingId.value = row.id
Object.assign(formData, {
oid_prefix: row.oid_prefix,
title: row.title,
description: row.description ?? '',
severity_code: row.severity_code ?? '',
recovery_message: row.recovery_message ?? '',
enabled: row.enabled,
})
modalVisible.value = true
}
async function submitForm() {
const err = await formRef.value?.validate()
if (err) return
submitting.value = true
try {
if (editingId.value != null) {
const res: any = await updateTrapDictionary(editingId.value, { ...formData })
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '保存失败')
return
}
Message.success('已保存')
} else {
const res: any = await createTrapDictionary({ ...formData })
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '创建失败')
return
}
Message.success('已创建')
}
modalVisible.value = false
await fetchList()
} catch (e: any) {
Message.error(e?.message || '请求失败')
} finally {
submitting.value = false
}
}
function handleDelete(row: TrapDictionaryEntry) {
if (!row.id) return
Modal.confirm({
title: '确认删除',
content: `删除 OID 前缀「${row.oid_prefix}」?`,
onOk: async () => {
try {
const res: any = await deleteTrapDictionary(row.id!)
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '删除失败')
return
}
Message.success('已删除')
await fetchList()
} catch (e: any) {
Message.error(e?.message || '删除失败')
}
},
})
}
fetchList()
loadSeverityOptions()
</script>
<script lang="ts">
export default { name: 'LogMgmtTrapDictionary' }
</script>
<style scoped lang="less">
.container {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,447 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="clientPagination"
title="SNMP Trap 匹配规则"
search-button-text="查询"
reset-button-text="重置"
:show-download="false"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@refresh="fetchList"
>
<template #toolbar-left>
<a-button type="primary" @click="openCreate">
<template #icon><icon-plus /></template>
新建规则
</a-button>
</template>
<template #enabled="{ record }">
<a-tag :color="record.enabled ? 'green' : 'red'">{{ record.enabled ? '启用' : '禁用' }}</a-tag>
</template>
<template #operations="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">删除</a-button>
</a-space>
</template>
</search-table>
<a-modal
v-model:visible="modalVisible"
:title="editingId ? `编辑规则 #${editingId}` : '新建 Trap 规则'"
width="640px"
:confirm-loading="submitting"
@ok="submitForm"
@cancel="modalVisible = false"
>
<a-form ref="formRef" :model="formData" layout="vertical">
<a-form-item field="name" label="规则名称" :rules="[{ required: true, message: '必填' }]">
<a-input v-model="formData.name" placeholder="规则名称" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="enabled" label="启用">
<a-switch v-model="formData.enabled" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="priority" label="优先级">
<a-input-number v-model="formData.priority" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="oid_prefix" label="OID 前缀">
<a-input v-model="formData.oid_prefix" placeholder="如 1.3.6.1.4.1." />
</a-form-item>
<a-form-item field="varbind_match_regex" label="Varbind 匹配正则">
<a-input v-model="formData.varbind_match_regex" placeholder="可选" />
</a-form-item>
<a-form-item field="alert_name" label="告警名称">
<a-input v-model="formData.alert_name" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="severity_code" label="严重级别代码">
<a-select
v-model="formData.severity_code"
placeholder="请选择告警级别"
:loading="severityLoading"
allow-search
:filter-option="false"
@search="handleSeveritySearch"
>
<a-option
v-for="item in severityOptions"
:key="item.code"
:value="item.code"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="policy_id" label="策略 ID">
<a-select
v-model="formData.policy_id"
placeholder="请选择告警策略"
:loading="policyLoading"
allow-search
:filter-option="false"
@search="handlePolicySearch"
@change="handlePolicyChange"
>
<a-option
v-for="item in policyOptions"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import { fetchPolicyList } from '@/api/ops/alertPolicy'
import { fetchSeverityOptions, type AlertSeverity } from '@/api/ops/alertTemplate'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormInstance } from '@arco-design/web-vue'
import {
createTrapRule,
deleteTrapRule,
fetchTrapRules,
unwrapLogsPayload,
updateTrapRule,
type TrapRule,
} from '@/api/ops/logs'
const loading = ref(false)
const tableData = ref<TrapRule[]>([])
const allRows = ref<TrapRule[]>([])
const formModel = ref({ keyword: '' })
function handleFormModelUpdate(v: Record<string, unknown>) {
formModel.value = v as { keyword: string }
}
const modalVisible = ref(false)
const submitting = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const emptyForm = (): TrapRule => ({
name: '',
enabled: true,
priority: 0,
oid_prefix: '',
varbind_match_regex: '',
alert_name: '',
severity_code: '',
policy_id: 0,
})
const formData = reactive<TrapRule>(emptyForm())
const severityOptions = ref<AlertSeverity[]>([])
const baseSeverityOptions = ref<AlertSeverity[]>([])
const severityLoading = ref(false)
let severitySearchTimer: ReturnType<typeof setTimeout> | null = null
type PolicyOption = { id: number; name: string }
const policyLoading = ref(false)
const policyOptions = ref<PolicyOption[]>([{ id: 0, name: '未绑定' }])
const basePolicyOptions = ref<PolicyOption[]>([{ id: 0, name: '未绑定' }])
let policySearchTimer: ReturnType<typeof setTimeout> | null = null
async function loadPolicyOptions(keyword?: string) {
policyLoading.value = true
try {
const res: any = await fetchPolicyList({
page: 1,
page_size: keyword ? 50 : 1000,
enabled: true,
keyword: keyword || undefined,
})
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data ?? res?.details?.list ?? res?.data?.data ?? res?.data ?? []
const fetchedOptions: PolicyOption[] = (Array.isArray(list) ? list : [])
.map((p: any) => ({ id: Number(p.id), name: p.name || '' }))
.filter((p: PolicyOption) => !Number.isNaN(p.id))
const isBaseLoad = !keyword || keyword.trim().length === 0
if (isBaseLoad) {
basePolicyOptions.value = [{ id: 0, name: '未绑定' }, ...fetchedOptions]
}
const selectedId = formData.policy_id
const selectedOptions = basePolicyOptions.value.filter((p) => p.id === selectedId)
const mergedMap = new Map<number, PolicyOption>()
;[...fetchedOptions, ...selectedOptions].forEach((item) => {
mergedMap.set(item.id, item)
})
mergedMap.set(0, { id: 0, name: '未绑定' })
policyOptions.value = Array.from(mergedMap.values())
} catch (e: any) {
console.error('加载告警策略失败:', e)
policyOptions.value = [{ id: 0, name: '未绑定' }]
basePolicyOptions.value = [{ id: 0, name: '未绑定' }]
} finally {
policyLoading.value = false
}
}
function handlePolicySearch(keyword: string) {
if (policySearchTimer) {
clearTimeout(policySearchTimer)
}
policySearchTimer = setTimeout(() => {
loadPolicyOptions(keyword)
}, 300)
}
function handlePolicyChange(v: any) {
formData.policy_id = Number(v)
}
async function loadSeverityOptions(keyword?: string) {
severityLoading.value = true
try {
const res: any = await fetchSeverityOptions({
keyword,
enabled: 'true',
})
const list = Array.isArray(res?.details)
? res.details
: res?.details?.data || res?.details?.list || res?.data || res
const fetchedOptions = Array.isArray(list) ? list : []
const isBaseLoad = !keyword || keyword.trim().length === 0
if (isBaseLoad) {
baseSeverityOptions.value = fetchedOptions
}
const selectedCode = formData.severity_code
const selectedOptions = baseSeverityOptions.value.filter((s) => s.code === selectedCode)
const mergedMap = new Map<string, AlertSeverity>()
;[...fetchedOptions, ...selectedOptions].forEach((item) => {
if (item?.code) mergedMap.set(item.code, item)
})
severityOptions.value = Array.from(mergedMap.values())
} catch (e: any) {
console.error('加载告警级别失败:', e)
severityOptions.value = []
} finally {
severityLoading.value = false
}
}
function handleSeveritySearch(keyword: string) {
if (severitySearchTimer) {
clearTimeout(severitySearchTimer)
}
severitySearchTimer = setTimeout(() => {
loadSeverityOptions(keyword)
}, 300)
}
const clientPagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formItems = computed<FormItem[]>(() => [
{ field: 'keyword', label: '关键词', type: 'input', placeholder: '规则名 / OID', span: 8 },
])
const columns = computed<TableColumnData[]>(() => [
{ title: 'ID', dataIndex: 'id', width: 72 },
{ title: '名称', dataIndex: 'name', ellipsis: true, tooltip: true },
{ title: '优先级', dataIndex: 'priority', width: 80 },
{ title: '启用', dataIndex: 'enabled', slotName: 'enabled', width: 88 },
{ title: 'OID 前缀', dataIndex: 'oid_prefix', ellipsis: true, tooltip: true },
{ title: 'Varbind 正则', dataIndex: 'varbind_match_regex', ellipsis: true, tooltip: true },
{ title: '告警名', dataIndex: 'alert_name', ellipsis: true, tooltip: true },
{ title: '级别', dataIndex: 'severity_code', width: 90 },
{ title: '策略ID', dataIndex: 'policy_id', width: 88 },
{ title: '操作', slotName: 'operations', width: 160, fixed: 'right' },
])
function applyFilter(list: TrapRule[]) {
const kw = formModel.value.keyword?.trim()
if (!kw) return list
return list.filter(
r =>
r.name?.includes(kw) ||
r.oid_prefix?.includes(kw) ||
r.alert_name?.includes(kw)
)
}
function slicePage(list: TrapRule[]) {
const start = (clientPagination.current - 1) * clientPagination.pageSize
tableData.value = list.slice(start, start + clientPagination.pageSize)
}
async function fetchList() {
loading.value = true
try {
const res: any = await fetchTrapRules()
const payload = unwrapLogsPayload(res)
const items = (payload?.items ?? []) as TrapRule[]
allRows.value = items
const filtered = applyFilter(items)
clientPagination.total = filtered.length
slicePage(filtered)
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '加载失败')
}
} catch (e: any) {
Message.error(e?.message || '加载失败')
allRows.value = []
tableData.value = []
clientPagination.total = 0
} finally {
loading.value = false
}
}
function handleSearch() {
clientPagination.current = 1
const filtered = applyFilter(allRows.value)
clientPagination.total = filtered.length
slicePage(filtered)
}
function handleReset() {
formModel.value = { keyword: '' }
clientPagination.current = 1
const filtered = applyFilter(allRows.value)
clientPagination.total = filtered.length
slicePage(filtered)
}
function handlePageChange(c: number) {
clientPagination.current = c
slicePage(applyFilter(allRows.value))
}
function handlePageSizeChange(size: number) {
clientPagination.pageSize = size
clientPagination.current = 1
slicePage(applyFilter(allRows.value))
}
function openCreate() {
editingId.value = null
Object.assign(formData, emptyForm())
modalVisible.value = true
}
function openEdit(row: TrapRule) {
if (!row.id) return
editingId.value = row.id
Object.assign(formData, {
name: row.name,
enabled: row.enabled,
priority: row.priority,
oid_prefix: row.oid_prefix ?? '',
varbind_match_regex: row.varbind_match_regex ?? '',
alert_name: row.alert_name ?? '',
severity_code: row.severity_code ?? '',
policy_id: row.policy_id ?? 0,
})
modalVisible.value = true
}
async function submitForm() {
const err = await formRef.value?.validate()
if (err) return
submitting.value = true
try {
if (editingId.value != null) {
const res: any = await updateTrapRule(editingId.value, { ...formData })
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '保存失败')
return
}
Message.success('已保存')
} else {
const res: any = await createTrapRule({ ...formData })
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '创建失败')
return
}
Message.success('已创建')
}
modalVisible.value = false
await fetchList()
} catch (e: any) {
Message.error(e?.message || '请求失败')
} finally {
submitting.value = false
}
}
function handleDelete(row: TrapRule) {
if (!row.id) return
Modal.confirm({
title: '确认删除',
content: `删除规则「${row.name}」?`,
onOk: async () => {
try {
const res: any = await deleteTrapRule(row.id!)
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '删除失败')
return
}
Message.success('已删除')
await fetchList()
} catch (e: any) {
Message.error(e?.message || '删除失败')
}
},
})
}
fetchList()
loadSeverityOptions()
loadPolicyOptions()
</script>
<script lang="ts">
export default { name: 'LogMgmtTrapRules' }
</script>
<style scoped lang="less">
.container {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,290 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="clientPagination"
title="Trap 屏蔽"
search-button-text="查询"
reset-button-text="重置"
:show-download="false"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@refresh="fetchList"
>
<template #toolbar-left>
<a-button type="primary" @click="openCreate">
<template #icon><icon-plus /></template>
新建屏蔽规则
</a-button>
</template>
<template #enabled="{ record }">
<a-tag :color="record.enabled ? 'green' : 'red'">{{ record.enabled ? '启用' : '禁用' }}</a-tag>
</template>
<template #operations="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">删除</a-button>
</a-space>
</template>
</search-table>
<a-modal
v-model:visible="modalVisible"
:title="editingId ? `编辑屏蔽 #${editingId}` : '新建 Trap 屏蔽'"
width="640px"
:confirm-loading="submitting"
@ok="submitForm"
@cancel="modalVisible = false"
>
<a-form ref="formRef" :model="formData" layout="vertical">
<a-form-item field="name" label="名称" :rules="[{ required: true, message: '必填' }]">
<a-input v-model="formData.name" />
</a-form-item>
<a-form-item field="enabled" label="启用">
<a-switch v-model="formData.enabled" />
</a-form-item>
<a-form-item field="source_ip_cidr" label="源 IP / CIDR">
<a-input v-model="formData.source_ip_cidr" placeholder="如 192.168.1.0/24" />
</a-form-item>
<a-form-item field="oid_prefix" label="OID 前缀">
<a-input v-model="formData.oid_prefix" />
</a-form-item>
<a-form-item field="interface_hint" label="接口提示">
<a-input v-model="formData.interface_hint" />
</a-form-item>
<a-form-item
field="time_windows_json"
label="时间窗 JSON"
extra="后端约定的 JSON 字符串,可为空"
>
<a-textarea v-model="formData.time_windows_json" :rows="4" placeholder="{}" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormInstance } from '@arco-design/web-vue'
import {
createTrapSuppression,
deleteTrapSuppression,
fetchTrapSuppressions,
unwrapLogsPayload,
updateTrapSuppression,
type TrapShield,
} from '@/api/ops/logs'
const loading = ref(false)
const tableData = ref<TrapShield[]>([])
const allRows = ref<TrapShield[]>([])
const formModel = ref({ keyword: '' })
function handleFormModelUpdate(v: Record<string, unknown>) {
formModel.value = v as { keyword: string }
}
const modalVisible = ref(false)
const submitting = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const emptyForm = (): TrapShield => ({
name: '',
enabled: true,
source_ip_cidr: '',
oid_prefix: '',
interface_hint: '',
time_windows_json: '',
})
const formData = reactive<TrapShield>(emptyForm())
const clientPagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formItems = computed<FormItem[]>(() => [
{ field: 'keyword', label: '关键词', type: 'input', placeholder: '名称 / OID / IP', span: 8 },
])
const columns = computed<TableColumnData[]>(() => [
{ title: 'ID', dataIndex: 'id', width: 72 },
{ title: '名称', dataIndex: 'name', ellipsis: true, tooltip: true },
{ title: '启用', dataIndex: 'enabled', slotName: 'enabled', width: 88 },
{ title: '源 IP/CIDR', dataIndex: 'source_ip_cidr', ellipsis: true, tooltip: true },
{ title: 'OID 前缀', dataIndex: 'oid_prefix', ellipsis: true, tooltip: true },
{ title: '接口提示', dataIndex: 'interface_hint', ellipsis: true, tooltip: true },
{ title: '操作', slotName: 'operations', width: 160, fixed: 'right' },
])
function applyFilter(list: TrapShield[]) {
const kw = formModel.value.keyword?.trim()
if (!kw) return list
return list.filter(
r =>
r.name?.includes(kw) ||
r.oid_prefix?.includes(kw) ||
r.source_ip_cidr?.includes(kw)
)
}
function slicePage(list: TrapShield[]) {
const start = (clientPagination.current - 1) * clientPagination.pageSize
tableData.value = list.slice(start, start + clientPagination.pageSize)
}
async function fetchList() {
loading.value = true
try {
const res: any = await fetchTrapSuppressions()
const payload = unwrapLogsPayload(res)
const items = (payload?.items ?? []) as TrapShield[]
allRows.value = items
const filtered = applyFilter(items)
clientPagination.total = filtered.length
slicePage(filtered)
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '加载失败')
}
} catch (e: any) {
Message.error(e?.message || '加载失败')
allRows.value = []
tableData.value = []
clientPagination.total = 0
} finally {
loading.value = false
}
}
function handleSearch() {
clientPagination.current = 1
const filtered = applyFilter(allRows.value)
clientPagination.total = filtered.length
slicePage(filtered)
}
function handleReset() {
formModel.value = { keyword: '' }
clientPagination.current = 1
const filtered = applyFilter(allRows.value)
clientPagination.total = filtered.length
slicePage(filtered)
}
function handlePageChange(c: number) {
clientPagination.current = c
slicePage(applyFilter(allRows.value))
}
function handlePageSizeChange(size: number) {
clientPagination.pageSize = size
clientPagination.current = 1
slicePage(applyFilter(allRows.value))
}
function openCreate() {
editingId.value = null
Object.assign(formData, emptyForm())
modalVisible.value = true
}
function openEdit(row: TrapShield) {
if (!row.id) return
editingId.value = row.id
Object.assign(formData, {
name: row.name,
enabled: row.enabled,
source_ip_cidr: row.source_ip_cidr ?? '',
oid_prefix: row.oid_prefix ?? '',
interface_hint: row.interface_hint ?? '',
time_windows_json: row.time_windows_json ?? '',
})
modalVisible.value = true
}
async function submitForm() {
const err = await formRef.value?.validate()
if (err) return
const tw = formData.time_windows_json?.trim()
if (tw) {
try {
JSON.parse(tw)
} catch {
Message.warning('时间窗 JSON 格式无效')
return
}
}
submitting.value = true
try {
if (editingId.value != null) {
const res: any = await updateTrapSuppression(editingId.value, { ...formData })
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '保存失败')
return
}
Message.success('已保存')
} else {
const res: any = await createTrapSuppression({ ...formData })
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '创建失败')
return
}
Message.success('已创建')
}
modalVisible.value = false
await fetchList()
} catch (e: any) {
Message.error(e?.message || '请求失败')
} finally {
submitting.value = false
}
}
function handleDelete(row: TrapShield) {
if (!row.id) return
Modal.confirm({
title: '确认删除',
content: `删除「${row.name}」?`,
onOk: async () => {
try {
const res: any = await deleteTrapSuppression(row.id!)
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '删除失败')
return
}
Message.success('已删除')
await fetchList()
} catch (e: any) {
Message.error(e?.message || '删除失败')
}
},
})
}
fetchList()
</script>
<script lang="ts">
export default { name: 'LogMgmtTrapSuppressions' }
</script>
<style scoped lang="less">
.container {
padding: 16px;
}
</style>

View File

@@ -1,542 +1,11 @@
<template>
<div class="container">
<!-- 统计卡片 -->
<a-row :gutter="16" class="stats-row">
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-primary">
<icon-file />
</div>
<div class="stats-info">
<div class="stats-title">今日日志</div>
<div class="stats-value">{{ stats.total }}</div>
<div class="stats-desc">总采集量</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-danger">
<icon-close-circle-fill />
</div>
<div class="stats-info">
<div class="stats-title">错误日志</div>
<div class="stats-value">{{ stats.error }}</div>
<div class="stats-desc text-danger">较昨日 +12%</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-warning">
<icon-exclamation-circle-fill />
</div>
<div class="stats-info">
<div class="stats-title">警告日志</div>
<div class="stats-value">{{ stats.warning }}</div>
<div class="stats-desc text-success">较昨日 -5%</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-primary">
<icon-info-circle-fill />
</div>
<div class="stats-info">
<div class="stats-title">信息日志</div>
<div class="stats-value">{{ stats.info }}</div>
<div class="stats-desc">正常范围</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 图表区域 -->
<a-row :gutter="16" class="chart-row">
<a-col :xs="24" :lg="12">
<a-card title="日志采集趋势" :bordered="false">
<template #extra>
<a-space>
<span class="legend-item">
<span class="legend-dot legend-dot-1"></span>
<span>总量</span>
</span>
<span class="legend-item">
<span class="legend-dot legend-dot-2"></span>
<span>异常</span>
</span>
</a-space>
</template>
<div class="chart-container">
<Chart :options="logTrendChartOptions" height="280px" />
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="日志来源分布" :bordered="false">
<template #extra>
<span class="text-muted">按系统统计</span>
</template>
<div class="source-list">
<div v-for="item in logSources" :key="item.name" class="source-item">
<div class="source-header">
<span class="source-name">{{ item.name }}</span>
<span class="source-value">{{ item.value }} ({{ item.percent }}%)</span>
</div>
<a-progress
:percent="item.percent"
:stroke-width="8"
:show-text="false"
:color="item.color"
/>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 日志列表 -->
<a-card title="实时日志" :bordered="false">
<template #extra>
<a-space>
<a-input-search
v-model="searchQuery"
placeholder="搜索日志..."
style="width: 250px"
allow-clear
/>
<a-select v-model="selectedLevel" placeholder="全部级别" style="width: 120px">
<a-option value="">全部级别</a-option>
<a-option value="error">ERROR</a-option>
<a-option value="warn">WARN</a-option>
<a-option value="info">INFO</a-option>
<a-option value="debug">DEBUG</a-option>
</a-select>
</a-space>
</template>
<a-table
:data="filteredLogs"
:columns="columns"
:loading="loading"
:pagination="false"
row-key="timestamp"
>
<!-- 级别列 -->
<template #level="{ record }">
<a-tag :color="getLevelColor(record.levelValue)" bordered>
{{ record.levelText }}
</a-tag>
</template>
<!-- 消息列 -->
<template #message="{ record }">
<span class="log-message">{{ record.message }}</span>
</template>
</a-table>
</a-card>
</div>
<LogMgmtEntries />
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import {
IconFile,
IconCloseCircleFill,
IconExclamationCircleFill,
IconInfoCircleFill,
} from '@arco-design/web-vue/es/icon'
import Breadcrumb from '@/components/breadcrumb/index.vue'
import Chart from '@/components/chart/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
// 统计数据
const stats = ref({
total: '2.4M',
error: '1,234',
warning: '3,567',
info: '2.39M',
})
const loading = ref(false)
const searchQuery = ref('')
const selectedLevel = ref('')
// 表格列配置
const columns: TableColumnData[] = [
{
title: '时间',
dataIndex: 'timestamp',
width: 180,
},
{
title: '级别',
dataIndex: 'level',
slotName: 'level',
width: 100,
align: 'center',
},
{
title: '来源',
dataIndex: 'source',
width: 150,
},
{
title: '消息内容',
dataIndex: 'message',
slotName: 'message',
},
{
title: '次数',
dataIndex: 'count',
width: 80,
align: 'center',
},
]
// 日志数据
const logData = ref([
{
timestamp: '2024-01-15 14:32:45',
levelValue: 'error',
levelText: 'ERROR',
source: 'Web-Server-01',
message: 'Connection refused: Unable to connect to database server',
count: '156',
},
{
timestamp: '2024-01-15 14:32:12',
levelValue: 'warn',
levelText: 'WARN',
source: 'API-Gateway',
message: 'Rate limit exceeded for IP 192.168.1.100',
count: '89',
},
{
timestamp: '2024-01-15 14:31:58',
levelValue: 'info',
levelText: 'INFO',
source: 'Auth-Service',
message: 'User login successful: admin@example.com',
count: '1',
},
{
timestamp: '2024-01-15 14:31:45',
levelValue: 'error',
levelText: 'ERROR',
source: 'Payment-Service',
message: 'Transaction failed: Insufficient funds',
count: '23',
},
{
timestamp: '2024-01-15 14:31:30',
levelValue: 'warn',
levelText: 'WARN',
source: 'Storage-Server',
message: 'Disk usage exceeded 85% threshold',
count: '5',
},
{
timestamp: '2024-01-15 14:31:15',
levelValue: 'info',
levelText: 'INFO',
source: 'Scheduler',
message: 'Backup job completed successfully',
count: '1',
},
{
timestamp: '2024-01-15 14:31:00',
levelValue: 'critical',
levelText: 'CRITICAL',
source: 'Core-Router',
message: 'Interface GigabitEthernet0/1 down',
count: '1',
},
{
timestamp: '2024-01-15 14:30:45',
levelValue: 'debug',
levelText: 'DEBUG',
source: 'Cache-Server',
message: 'Cache hit ratio: 94.5%',
count: '1',
},
])
// 过滤后的日志列表
const filteredLogs = computed(() => {
let result = logData.value
if (selectedLevel.value) {
result = result.filter((log) => log.levelValue === selectedLevel.value)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(log) =>
log.message.toLowerCase().includes(query) ||
log.source.toLowerCase().includes(query)
)
}
return result
})
// 日志来源分布数据
const logSources = ref([
{ name: 'Web服务器', value: '856K', percent: 35, color: '#165DFF' },
{ name: '数据库', value: '624K', percent: 26, color: '#14C9C9' },
{ name: '应用服务', value: '480K', percent: 20, color: '#F7BA1E' },
{ name: '网络设备', value: '288K', percent: 12, color: '#722ED1' },
{ name: '安全设备', value: '168K', percent: 7, color: '#F53F3F' },
])
// 日志趋势图表配置
const logTrendChartOptions = ref({
tooltip: {
trigger: 'axis',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00'],
},
yAxis: {
type: 'value',
name: '数量',
},
series: [
{
name: '总量',
type: 'line',
smooth: true,
data: [1200, 800, 2800, 4500, 3800, 2200, 1500],
areaStyle: {
opacity: 0.1,
},
lineStyle: {
width: 2,
color: '#165DFF',
},
itemStyle: {
color: '#165DFF',
},
},
{
name: '异常',
type: 'line',
smooth: true,
data: [89, 45, 156, 234, 189, 112, 78],
areaStyle: {
opacity: 0.1,
},
lineStyle: {
width: 2,
color: '#F53F3F',
},
itemStyle: {
color: '#F53F3F',
},
},
],
})
// 获取级别颜色
const getLevelColor = (level: string) => {
const colorMap: Record<string, string> = {
error: 'red',
critical: 'red',
warn: 'orange',
info: 'green',
debug: 'gray',
}
return colorMap[level] || 'gray'
}
// 获取数据
const fetchData = async () => {
// TODO: 从API获取数据
loading.value = false
}
// 初始化
onMounted(() => {
fetchData()
})
import LogMgmtEntries from '@/views/ops/pages/log-mgmt/entries/index.vue'
</script>
<script lang="ts">
export default {
name: 'LogMonitor',
}
export default { name: 'MonitorLog' }
</script>
<style scoped lang="less">
.container {
padding: 16px;
}
.stats-row {
margin-bottom: 16px;
}
.stats-card {
height: 100%;
:deep(.arco-card-body) {
padding: 16px;
}
.stats-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
&-primary {
background-color: rgba(22, 93, 255, 0.1);
color: rgb(var(--primary-6));
}
&-success {
background-color: rgba(0, 180, 42, 0.1);
color: rgb(var(--success-6));
}
&-danger {
background-color: rgba(245, 63, 63, 0.1);
color: rgb(var(--danger-6));
}
&-warning {
background-color: rgba(255, 125, 0, 0.1);
color: rgb(var(--warning-6));
}
&-muted {
background-color: rgba(134, 144, 156, 0.1);
color: rgb(var(--gray-6));
}
}
.stats-info {
flex: 1;
}
.stats-title {
font-size: 14px;
color: var(--color-text-3);
margin-bottom: 4px;
}
.stats-value {
font-size: 24px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.2;
margin-bottom: 4px;
}
.stats-desc {
font-size: 12px;
color: var(--color-text-3);
}
}
.chart-row {
margin-bottom: 16px;
}
.chart-container {
height: 280px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-text-3);
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&-1 {
background-color: #165DFF;
}
&-2 {
background-color: #F53F3F;
}
}
.text-muted {
color: var(--color-text-3);
}
.text-danger {
color: rgb(var(--danger-6));
}
.text-success {
color: rgb(var(--success-6));
}
.source-list {
display: flex;
flex-direction: column;
gap: 16px;
height: 280px;
}
.source-item {
.source-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.source-name {
font-size: 14px;
color: var(--color-text-2);
}
.source-value {
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
}
}
.log-message {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,542 @@
<template>
<div class="container">
<!-- 统计卡片 -->
<a-row :gutter="16" class="stats-row">
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-primary">
<icon-file />
</div>
<div class="stats-info">
<div class="stats-title">今日日志</div>
<div class="stats-value">{{ stats.total }}</div>
<div class="stats-desc">总采集量</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-danger">
<icon-close-circle-fill />
</div>
<div class="stats-info">
<div class="stats-title">错误日志</div>
<div class="stats-value">{{ stats.error }}</div>
<div class="stats-desc text-danger">较昨日 +12%</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-warning">
<icon-exclamation-circle-fill />
</div>
<div class="stats-info">
<div class="stats-title">警告日志</div>
<div class="stats-value">{{ stats.warning }}</div>
<div class="stats-desc text-success">较昨日 -5%</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-primary">
<icon-info-circle-fill />
</div>
<div class="stats-info">
<div class="stats-title">信息日志</div>
<div class="stats-value">{{ stats.info }}</div>
<div class="stats-desc">正常范围</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 图表区域 -->
<a-row :gutter="16" class="chart-row">
<a-col :xs="24" :lg="12">
<a-card title="日志采集趋势" :bordered="false">
<template #extra>
<a-space>
<span class="legend-item">
<span class="legend-dot legend-dot-1"></span>
<span>总量</span>
</span>
<span class="legend-item">
<span class="legend-dot legend-dot-2"></span>
<span>异常</span>
</span>
</a-space>
</template>
<div class="chart-container">
<Chart :options="logTrendChartOptions" height="280px" />
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="日志来源分布" :bordered="false">
<template #extra>
<span class="text-muted">按系统统计</span>
</template>
<div class="source-list">
<div v-for="item in logSources" :key="item.name" class="source-item">
<div class="source-header">
<span class="source-name">{{ item.name }}</span>
<span class="source-value">{{ item.value }} ({{ item.percent }}%)</span>
</div>
<a-progress
:percent="item.percent"
:stroke-width="8"
:show-text="false"
:color="item.color"
/>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 日志列表 -->
<a-card title="实时日志" :bordered="false">
<template #extra>
<a-space>
<a-input-search
v-model="searchQuery"
placeholder="搜索日志..."
style="width: 250px"
allow-clear
/>
<a-select v-model="selectedLevel" placeholder="全部级别" style="width: 120px">
<a-option value="">全部级别</a-option>
<a-option value="error">ERROR</a-option>
<a-option value="warn">WARN</a-option>
<a-option value="info">INFO</a-option>
<a-option value="debug">DEBUG</a-option>
</a-select>
</a-space>
</template>
<a-table
:data="filteredLogs"
:columns="columns"
:loading="loading"
:pagination="false"
row-key="timestamp"
>
<!-- 级别列 -->
<template #level="{ record }">
<a-tag :color="getLevelColor(record.levelValue)" bordered>
{{ record.levelText }}
</a-tag>
</template>
<!-- 消息列 -->
<template #message="{ record }">
<span class="log-message">{{ record.message }}</span>
</template>
</a-table>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import {
IconFile,
IconCloseCircleFill,
IconExclamationCircleFill,
IconInfoCircleFill,
} from '@arco-design/web-vue/es/icon'
import Breadcrumb from '@/components/breadcrumb/index.vue'
import Chart from '@/components/chart/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
// 统计数据
const stats = ref({
total: '2.4M',
error: '1,234',
warning: '3,567',
info: '2.39M',
})
const loading = ref(false)
const searchQuery = ref('')
const selectedLevel = ref('')
// 表格列配置
const columns: TableColumnData[] = [
{
title: '时间',
dataIndex: 'timestamp',
width: 180,
},
{
title: '级别',
dataIndex: 'level',
slotName: 'level',
width: 100,
align: 'center',
},
{
title: '来源',
dataIndex: 'source',
width: 150,
},
{
title: '消息内容',
dataIndex: 'message',
slotName: 'message',
},
{
title: '次数',
dataIndex: 'count',
width: 80,
align: 'center',
},
]
// 日志数据
const logData = ref([
{
timestamp: '2024-01-15 14:32:45',
levelValue: 'error',
levelText: 'ERROR',
source: 'Web-Server-01',
message: 'Connection refused: Unable to connect to database server',
count: '156',
},
{
timestamp: '2024-01-15 14:32:12',
levelValue: 'warn',
levelText: 'WARN',
source: 'API-Gateway',
message: 'Rate limit exceeded for IP 192.168.1.100',
count: '89',
},
{
timestamp: '2024-01-15 14:31:58',
levelValue: 'info',
levelText: 'INFO',
source: 'Auth-Service',
message: 'User login successful: admin@example.com',
count: '1',
},
{
timestamp: '2024-01-15 14:31:45',
levelValue: 'error',
levelText: 'ERROR',
source: 'Payment-Service',
message: 'Transaction failed: Insufficient funds',
count: '23',
},
{
timestamp: '2024-01-15 14:31:30',
levelValue: 'warn',
levelText: 'WARN',
source: 'Storage-Server',
message: 'Disk usage exceeded 85% threshold',
count: '5',
},
{
timestamp: '2024-01-15 14:31:15',
levelValue: 'info',
levelText: 'INFO',
source: 'Scheduler',
message: 'Backup job completed successfully',
count: '1',
},
{
timestamp: '2024-01-15 14:31:00',
levelValue: 'critical',
levelText: 'CRITICAL',
source: 'Core-Router',
message: 'Interface GigabitEthernet0/1 down',
count: '1',
},
{
timestamp: '2024-01-15 14:30:45',
levelValue: 'debug',
levelText: 'DEBUG',
source: 'Cache-Server',
message: 'Cache hit ratio: 94.5%',
count: '1',
},
])
// 过滤后的日志列表
const filteredLogs = computed(() => {
let result = logData.value
if (selectedLevel.value) {
result = result.filter((log) => log.levelValue === selectedLevel.value)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(log) =>
log.message.toLowerCase().includes(query) ||
log.source.toLowerCase().includes(query)
)
}
return result
})
// 日志来源分布数据
const logSources = ref([
{ name: 'Web服务器', value: '856K', percent: 35, color: '#165DFF' },
{ name: '数据库', value: '624K', percent: 26, color: '#14C9C9' },
{ name: '应用服务', value: '480K', percent: 20, color: '#F7BA1E' },
{ name: '网络设备', value: '288K', percent: 12, color: '#722ED1' },
{ name: '安全设备', value: '168K', percent: 7, color: '#F53F3F' },
])
// 日志趋势图表配置
const logTrendChartOptions = ref({
tooltip: {
trigger: 'axis',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00'],
},
yAxis: {
type: 'value',
name: '数量',
},
series: [
{
name: '总量',
type: 'line',
smooth: true,
data: [1200, 800, 2800, 4500, 3800, 2200, 1500],
areaStyle: {
opacity: 0.1,
},
lineStyle: {
width: 2,
color: '#165DFF',
},
itemStyle: {
color: '#165DFF',
},
},
{
name: '异常',
type: 'line',
smooth: true,
data: [89, 45, 156, 234, 189, 112, 78],
areaStyle: {
opacity: 0.1,
},
lineStyle: {
width: 2,
color: '#F53F3F',
},
itemStyle: {
color: '#F53F3F',
},
},
],
})
// 获取级别颜色
const getLevelColor = (level: string) => {
const colorMap: Record<string, string> = {
error: 'red',
critical: 'red',
warn: 'orange',
info: 'green',
debug: 'gray',
}
return colorMap[level] || 'gray'
}
// 获取数据
const fetchData = async () => {
// TODO: 从API获取数据
loading.value = false
}
// 初始化
onMounted(() => {
fetchData()
})
</script>
<script lang="ts">
export default {
name: 'LogMonitor',
}
</script>
<style scoped lang="less">
.container {
padding: 16px;
}
.stats-row {
margin-bottom: 16px;
}
.stats-card {
height: 100%;
:deep(.arco-card-body) {
padding: 16px;
}
.stats-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
&-primary {
background-color: rgba(22, 93, 255, 0.1);
color: rgb(var(--primary-6));
}
&-success {
background-color: rgba(0, 180, 42, 0.1);
color: rgb(var(--success-6));
}
&-danger {
background-color: rgba(245, 63, 63, 0.1);
color: rgb(var(--danger-6));
}
&-warning {
background-color: rgba(255, 125, 0, 0.1);
color: rgb(var(--warning-6));
}
&-muted {
background-color: rgba(134, 144, 156, 0.1);
color: rgb(var(--gray-6));
}
}
.stats-info {
flex: 1;
}
.stats-title {
font-size: 14px;
color: var(--color-text-3);
margin-bottom: 4px;
}
.stats-value {
font-size: 24px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.2;
margin-bottom: 4px;
}
.stats-desc {
font-size: 12px;
color: var(--color-text-3);
}
}
.chart-row {
margin-bottom: 16px;
}
.chart-container {
height: 280px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-text-3);
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&-1 {
background-color: #165DFF;
}
&-2 {
background-color: #F53F3F;
}
}
.text-muted {
color: var(--color-text-3);
}
.text-danger {
color: rgb(var(--danger-6));
}
.text-success {
color: rgb(var(--success-6));
}
.source-list {
display: flex;
flex-direction: column;
gap: 16px;
height: 280px;
}
.source-item {
.source-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.source-name {
font-size: 14px;
color: var(--color-text-2);
}
.source-value {
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
}
}
.log-message {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
}
</style>

View File

@@ -2,331 +2,48 @@
<div class="container">
<Breadcrumb :items="['menu.ops.systemSettings', 'menu.ops.systemSettings.systemLogs']" />
<SearchTable
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="系统日志"
search-button-text="查询"
reset-button-text="重置"
download-button-text="导出"
refresh-tooltip-text="刷新数据"
density-tooltip-text="表格密度"
column-setting-tooltip-text="列设置"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
@download="handleDownload"
>
<template #level="{ record }">
<a-tag :color="getLevelColor(record.level)">
{{ record.level }}
</a-tag>
<a-card class="page-card" :bordered="false" :body-style="{ padding: 0 }">
<template #title>
<div class="page-title">日志管理</div>
</template>
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<template #operations="{ record }">
<a-button type="text" size="small" @click="handleView(record)">
查看
</a-button>
</template>
</SearchTable>
<a-drawer
v-model:visible="detailVisible"
:width="480"
placement="right"
:title="detailRecord ? `日志详情 #${detailRecord.id}` : '日志详情'"
:footer="false"
unmount-on-close
>
<template v-if="detailRecord">
<a-descriptions :column="1" size="large" bordered>
<a-descriptions-item label="日志级别">
<a-tag :color="getLevelColor(detailRecord.level)">
{{ detailRecord.level }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="模块">
{{ detailRecord.module }}
</a-descriptions-item>
<a-descriptions-item label="操作人">
{{ detailRecord.operator }}
</a-descriptions-item>
<a-descriptions-item label="IP 地址">
{{ detailRecord.ip }}
</a-descriptions-item>
<a-descriptions-item label="操作时间">
{{ detailRecord.createdAt }}
</a-descriptions-item>
<a-descriptions-item label="请求 ID">
{{ detailRecord.requestId }}
</a-descriptions-item>
<a-descriptions-item label="日志内容">
<div class="detail-content">{{ detailRecord.content }}</div>
</a-descriptions-item>
</a-descriptions>
</template>
</a-drawer>
<a-tabs v-model:active-key="activeKey" size="small" destroy-on-hide>
<a-tab-pane key="entries" title="日志查询">
<LogMgmtEntries />
</a-tab-pane>
<a-tab-pane key="syslog-rules" title="Syslog 规则">
<LogMgmtSyslogRules />
</a-tab-pane>
<a-tab-pane key="trap-rules" title="Trap 规则">
<LogMgmtTrapRules />
</a-tab-pane>
<a-tab-pane key="trap-dictionary" title="Trap 字典">
<LogMgmtTrapDictionary />
</a-tab-pane>
<a-tab-pane key="trap-suppressions" title="Trap 屏蔽/抑制">
<LogMgmtTrapSuppressions />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormItem } from '@/components/search-form/types'
import { ref } from 'vue'
interface LogRecord {
id: number
level: string
module: string
content: string
operator: string
ip: string
createdAt: string
/** 用于时间范围筛选(毫秒时间戳) */
timestamp: number
requestId: string
}
import Breadcrumb from '@/components/breadcrumb/index.vue'
const generateMockData = (count: number): LogRecord[] => {
const levels = ['INFO', 'WARN', 'ERROR', 'DEBUG']
const modules = ['用户管理', '权限管理', '系统配置', '数据备份', '登录认证']
const operators = ['管理员', '张三', '李四', '系统', '定时任务']
import LogMgmtEntries from '@/views/ops/pages/log-mgmt/entries/index.vue'
import LogMgmtSyslogRules from '@/views/ops/pages/log-mgmt/syslog-rules/index.vue'
import LogMgmtTrapRules from '@/views/ops/pages/log-mgmt/trap-rules/index.vue'
import LogMgmtTrapDictionary from '@/views/ops/pages/log-mgmt/trap-dictionary/index.vue'
import LogMgmtTrapSuppressions from '@/views/ops/pages/log-mgmt/trap-suppressions/index.vue'
return Array.from({ length: count }, (_, i) => {
const timestamp = Date.now() - i * 3600000
return {
id: i + 1,
level: levels[i % levels.length],
module: modules[i % modules.length],
content: `日志内容描述 ${i + 1}:系统执行例行检查与状态同步。`,
operator: operators[i % operators.length],
ip: `192.168.${Math.floor(i / 255) % 256}.${i % 256}`,
createdAt: new Date(timestamp).toLocaleString('zh-CN'),
timestamp,
requestId: `req-${10000 + i}`,
}
})
}
const loading = ref(false)
const tableData = ref<LogRecord[]>([])
const allFilteredData = ref<LogRecord[]>([])
const formModel = ref({
level: '',
module: '',
operator: '',
keyword: '',
dateRange: [] as unknown[],
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const detailVisible = ref(false)
const detailRecord = ref<LogRecord | null>(null)
const formItems = computed<FormItem[]>(() => [
{
field: 'level',
label: '日志级别',
type: 'select',
placeholder: '请选择日志级别',
options: [
{ label: 'INFO', value: 'INFO' },
{ label: 'WARN', value: 'WARN' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'DEBUG', value: 'DEBUG' },
],
},
{
field: 'module',
label: '模块',
type: 'select',
placeholder: '请选择模块',
options: [
{ label: '用户管理', value: '用户管理' },
{ label: '权限管理', value: '权限管理' },
{ label: '系统配置', value: '系统配置' },
{ label: '数据备份', value: '数据备份' },
{ label: '登录认证', value: '登录认证' },
],
},
{
field: 'operator',
label: '操作人',
type: 'input',
placeholder: '请输入操作人',
},
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '搜索日志内容',
},
{
field: 'dateRange',
label: '时间范围',
type: 'dateRange',
placeholder: '选择时间范围',
span: 16,
},
])
const columns = computed<TableColumnData[]>(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '日志级别',
dataIndex: 'level',
slotName: 'level',
width: 100,
},
{
title: '模块',
dataIndex: 'module',
width: 120,
},
{
title: '日志内容',
dataIndex: 'content',
ellipsis: true,
tooltip: true,
},
{
title: '操作人',
dataIndex: 'operator',
width: 120,
},
{
title: 'IP地址',
dataIndex: 'ip',
width: 140,
},
{
title: '操作时间',
dataIndex: 'createdAt',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
slotName: 'operations',
width: 100,
fixed: 'right',
},
])
const getLevelColor = (level: string) => {
const colorMap: Record<string, string> = {
INFO: 'arcoblue',
WARN: 'orange',
ERROR: 'red',
DEBUG: 'gray',
}
return colorMap[level] || 'gray'
}
function applyFilters(source: LogRecord[]): LogRecord[] {
let data = source
const f = formModel.value
if (f.level) {
data = data.filter(item => item.level === f.level)
}
if (f.module) {
data = data.filter(item => item.module === f.module)
}
if (f.operator) {
data = data.filter(item => item.operator.includes(f.operator))
}
if (f.keyword?.trim()) {
const kw = f.keyword.trim()
data = data.filter(item => item.content.includes(kw))
}
if (f.dateRange && f.dateRange.length === 2) {
const [start, end] = f.dateRange
const startMs = new Date(start as string | Date).getTime()
const endMs = new Date(end as string | Date).getTime()
if (!Number.isNaN(startMs) && !Number.isNaN(endMs)) {
data = data.filter(item => item.timestamp >= startMs && item.timestamp <= endMs)
}
}
return data
}
function slicePage(data: LogRecord[]) {
const start = (pagination.current - 1) * pagination.pageSize
const end = start + pagination.pageSize
tableData.value = data.slice(start, end)
}
const fetchData = async () => {
loading.value = true
await new Promise(resolve => setTimeout(resolve, 400))
const base = generateMockData(100)
const filtered = applyFilters(base)
allFilteredData.value = filtered
pagination.total = filtered.length
slicePage(filtered)
loading.value = false
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleReset = () => {
formModel.value = {
level: '',
module: '',
operator: '',
keyword: '',
dateRange: [],
}
pagination.current = 1
fetchData()
}
const handlePageChange = (current: number) => {
pagination.current = current
slicePage(allFilteredData.value)
}
const handleRefresh = () => {
fetchData()
Message.success('数据已刷新')
}
const handleDownload = () => {
Message.info('导出功能开发中...')
}
const handleView = (record: LogRecord) => {
detailRecord.value = record
detailVisible.value = true
}
fetchData()
const activeKey = ref<'entries' | 'syslog-rules' | 'trap-rules' | 'trap-dictionary' | 'trap-suppressions'>('entries')
</script>
<script lang="ts">
@@ -340,10 +57,17 @@ export default {
padding: 0 20px 20px 20px;
}
.detail-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
.page-card {
:deep(.arco-card-body) {
padding-top: 0;
}
}
.page-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.4;
}
</style>

View File

@@ -0,0 +1,349 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.ops.systemSettings', 'menu.ops.systemSettings.systemLogs']" />
<SearchTable
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="系统日志"
search-button-text="查询"
reset-button-text="重置"
download-button-text="导出"
refresh-tooltip-text="刷新数据"
density-tooltip-text="表格密度"
column-setting-tooltip-text="列设置"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
@download="handleDownload"
>
<template #level="{ record }">
<a-tag :color="getLevelColor(record.level)">
{{ record.level }}
</a-tag>
</template>
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<template #operations="{ record }">
<a-button type="text" size="small" @click="handleView(record)">
查看
</a-button>
</template>
</SearchTable>
<a-drawer
v-model:visible="detailVisible"
:width="480"
placement="right"
:title="detailRecord ? `日志详情 #${detailRecord.id}` : '日志详情'"
:footer="false"
unmount-on-close
>
<template v-if="detailRecord">
<a-descriptions :column="1" size="large" bordered>
<a-descriptions-item label="日志级别">
<a-tag :color="getLevelColor(detailRecord.level)">
{{ detailRecord.level }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="模块">
{{ detailRecord.module }}
</a-descriptions-item>
<a-descriptions-item label="操作人">
{{ detailRecord.operator }}
</a-descriptions-item>
<a-descriptions-item label="IP 地址">
{{ detailRecord.ip }}
</a-descriptions-item>
<a-descriptions-item label="操作时间">
{{ detailRecord.createdAt }}
</a-descriptions-item>
<a-descriptions-item label="请求 ID">
{{ detailRecord.requestId }}
</a-descriptions-item>
<a-descriptions-item label="日志内容">
<div class="detail-content">{{ detailRecord.content }}</div>
</a-descriptions-item>
</a-descriptions>
</template>
</a-drawer>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormItem } from '@/components/search-form/types'
interface LogRecord {
id: number
level: string
module: string
content: string
operator: string
ip: string
createdAt: string
/** 用于时间范围筛选(毫秒时间戳) */
timestamp: number
requestId: string
}
const generateMockData = (count: number): LogRecord[] => {
const levels = ['INFO', 'WARN', 'ERROR', 'DEBUG']
const modules = ['用户管理', '权限管理', '系统配置', '数据备份', '登录认证']
const operators = ['管理员', '张三', '李四', '系统', '定时任务']
return Array.from({ length: count }, (_, i) => {
const timestamp = Date.now() - i * 3600000
return {
id: i + 1,
level: levels[i % levels.length],
module: modules[i % modules.length],
content: `日志内容描述 ${i + 1}:系统执行例行检查与状态同步。`,
operator: operators[i % operators.length],
ip: `192.168.${Math.floor(i / 255) % 256}.${i % 256}`,
createdAt: new Date(timestamp).toLocaleString('zh-CN'),
timestamp,
requestId: `req-${10000 + i}`,
}
})
}
const loading = ref(false)
const tableData = ref<LogRecord[]>([])
const allFilteredData = ref<LogRecord[]>([])
const formModel = ref({
level: '',
module: '',
operator: '',
keyword: '',
dateRange: [] as unknown[],
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const detailVisible = ref(false)
const detailRecord = ref<LogRecord | null>(null)
const formItems = computed<FormItem[]>(() => [
{
field: 'level',
label: '日志级别',
type: 'select',
placeholder: '请选择日志级别',
options: [
{ label: 'INFO', value: 'INFO' },
{ label: 'WARN', value: 'WARN' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'DEBUG', value: 'DEBUG' },
],
},
{
field: 'module',
label: '模块',
type: 'select',
placeholder: '请选择模块',
options: [
{ label: '用户管理', value: '用户管理' },
{ label: '权限管理', value: '权限管理' },
{ label: '系统配置', value: '系统配置' },
{ label: '数据备份', value: '数据备份' },
{ label: '登录认证', value: '登录认证' },
],
},
{
field: 'operator',
label: '操作人',
type: 'input',
placeholder: '请输入操作人',
},
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '搜索日志内容',
},
{
field: 'dateRange',
label: '时间范围',
type: 'dateRange',
placeholder: '选择时间范围',
span: 16,
},
])
const columns = computed<TableColumnData[]>(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '日志级别',
dataIndex: 'level',
slotName: 'level',
width: 100,
},
{
title: '模块',
dataIndex: 'module',
width: 120,
},
{
title: '日志内容',
dataIndex: 'content',
ellipsis: true,
tooltip: true,
},
{
title: '操作人',
dataIndex: 'operator',
width: 120,
},
{
title: 'IP地址',
dataIndex: 'ip',
width: 140,
},
{
title: '操作时间',
dataIndex: 'createdAt',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
slotName: 'operations',
width: 100,
fixed: 'right',
},
])
const getLevelColor = (level: string) => {
const colorMap: Record<string, string> = {
INFO: 'arcoblue',
WARN: 'orange',
ERROR: 'red',
DEBUG: 'gray',
}
return colorMap[level] || 'gray'
}
function applyFilters(source: LogRecord[]): LogRecord[] {
let data = source
const f = formModel.value
if (f.level) {
data = data.filter(item => item.level === f.level)
}
if (f.module) {
data = data.filter(item => item.module === f.module)
}
if (f.operator) {
data = data.filter(item => item.operator.includes(f.operator))
}
if (f.keyword?.trim()) {
const kw = f.keyword.trim()
data = data.filter(item => item.content.includes(kw))
}
if (f.dateRange && f.dateRange.length === 2) {
const [start, end] = f.dateRange
const startMs = new Date(start as string | Date).getTime()
const endMs = new Date(end as string | Date).getTime()
if (!Number.isNaN(startMs) && !Number.isNaN(endMs)) {
data = data.filter(item => item.timestamp >= startMs && item.timestamp <= endMs)
}
}
return data
}
function slicePage(data: LogRecord[]) {
const start = (pagination.current - 1) * pagination.pageSize
const end = start + pagination.pageSize
tableData.value = data.slice(start, end)
}
const fetchData = async () => {
loading.value = true
await new Promise(resolve => setTimeout(resolve, 400))
const base = generateMockData(100)
const filtered = applyFilters(base)
allFilteredData.value = filtered
pagination.total = filtered.length
slicePage(filtered)
loading.value = false
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleReset = () => {
formModel.value = {
level: '',
module: '',
operator: '',
keyword: '',
dateRange: [],
}
pagination.current = 1
fetchData()
}
const handlePageChange = (current: number) => {
pagination.current = current
slicePage(allFilteredData.value)
}
const handleRefresh = () => {
fetchData()
Message.success('数据已刷新')
}
const handleDownload = () => {
Message.info('导出功能开发中...')
}
const handleView = (record: LogRecord) => {
detailRecord.value = record
detailVisible.value = true
}
fetchData()
</script>
<script lang="ts">
export default {
name: 'SystemLogs',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
.detail-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
color: var(--color-text-1);
}
</style>