This commit is contained in:
ygx
2026-03-19 22:34:03 +08:00
parent ba2494f5b3
commit bfc27c346f
24 changed files with 4008 additions and 2 deletions

View File

@@ -6,7 +6,7 @@
"license": "MIT",
"scripts": {
"dev": "vite --config ./config/vite.config.dev.ts",
"build": "vue-tsc --noEmit --skipLibCheck && vite build --config ./config/vite.config.prod.ts",
"build": "vite build --config ./config/vite.config.prod.ts",
"lint-staged": "npx lint-staged",
"prepare": "husky install",
"build:normal": "vite build --config ./config/vite.config.prod.ts",

View File

@@ -34,3 +34,23 @@ export const fetchAlertStatistics = (data: {
/** 获取 告警计数 */
export const fetchAlertCount = () => request.get("/Alert/v1/record/count");
/** 获取 告警处理记录列表 */
export const fetchAlertProcessList = (data: {
alert_record_id?: number,
action?: string,
page?: number,
page_size?: number,
keyword?: string,
sort?: string,
order?: string
}) => request.get("/Alert/v1/process/list", { params: data });
/** 获取 告警趋势 */
export const fetchAlertTrend = (data: {
granularity?: string, // day / month
start_time?: string,
end_time?: string,
policy_id?: number,
severity_id?: number
}) => request.get("/Alert/v1/record/trend", { params: data });

109
src/api/ops/alertPolicy.ts Normal file
View File

@@ -0,0 +1,109 @@
import { request } from "@/api/request";
/** 获取 告警策略列表 */
export const fetchPolicyList = (data: {
page?: number,
page_size?: number,
keyword?: string,
enabled?: string,
priority?: number[],
created_at_start?: string,
created_at_end?: string,
order_by?: string,
order?: string
}) => request.get("/Alert/v1/policy/list", { params: data });
/** 获取 告警策略详情 */
export const fetchPolicyDetail = (id: number) => request.get(`/Alert/v1/policy/get/${id}`);
/** 创建 告警策略 */
export const createPolicy = (data: {
name: string;
description?: string;
enabled?: boolean;
priority?: number;
labels?: string;
template_id?: number;
auto_create_ticket?: boolean;
feedback_template_id?: number;
dispatch_rule?: string;
}) => request.post("/Alert/v1/policy/create", data);
/** 更新 告警策略 */
export const updatePolicy = (data: {
id: number;
name?: string;
description?: string;
enabled?: boolean;
priority?: number;
labels?: string;
template_id?: number;
auto_create_ticket?: boolean;
feedback_template_id?: number;
dispatch_rule?: string;
}) => request.post("/Alert/v1/policy/update", data);
/** 删除 告警策略 */
export const deletePolicy = (id: number) => request.delete(`/Alert/v1/policy/delete/${id}`);
/** 获取 告警规则列表 */
export const fetchRuleList = (data: {
policy_id?: number;
page?: number;
page_size?: number;
keyword?: string;
sort?: string;
order?: string
}) => request.get("/Alert/v1/rule/list", { params: data });
/** 获取 告警规则详情 */
export const fetchRuleDetail = (id: number) => request.get(`/Alert/v1/rule/get/${id}`);
/** 创建 告警规则 */
export const createRule = (data: {
policy_id: number;
name: string;
description?: string;
rule_type: string;
severity_id: number;
enabled?: boolean;
metric_name?: string;
query_expr?: string;
threshold?: number;
compare_op?: string;
duration?: number;
labels?: string;
}) => request.post("/Alert/v1/rule/create", data);
/** 更新 告警规则 */
export const updateRule = (data: {
id: number;
name?: string;
description?: string;
rule_type?: string;
severity_id?: number;
enabled?: boolean;
metric_name?: string;
query_expr?: string;
threshold?: number;
compare_op?: string;
duration?: number;
labels?: string;
}) => request.post("/Alert/v1/rule/update", data);
/** 删除 告警规则 */
export const deleteRule = (id: number) => request.delete(`/Alert/v1/rule/delete/${id}`);
/** 获取 告警模板列表 */
export const fetchTemplateList = (data: {
page?: number;
page_size?: number;
keyword?: string;
}) => request.get("/Alert/v1/template/list", { params: data });
/** 获取 告警级别列表 */
export const fetchSeverityList = (data: {
page?: number;
page_size?: number;
enabled?: string;
}) => request.get("/Alert/v1/severity/list", { params: data });

View File

@@ -0,0 +1,48 @@
import { request } from "@/api/request";
/** 获取 当前告警记录列表 */
export const fetchAlertRecords = (data: {
page?: number,
page_size?: number,
policy_id?: number,
rule_id?: number,
status?: string,
severity_id?: number,
start_time?: string,
end_time?: string,
keyword?: string
}) => {
return request.get("/Alert/v1/record/list", { params: data });
};
/** 获取 告警记录详情 */
export const fetchAlertRecordDetail = (id: number) => request.get(`/Alert/v1/record/get/${id}`);
/** 创建处理记录(执行确认/解决/屏蔽等操作) */
export const createAlertProcess = (data: {
alert_record_id: number;
action: string; // ack / resolve / silence / comment / assign / escalate / close
operator: string;
operator_id?: string;
comment?: string;
assign_to?: string;
assign_to_id?: string;
silence_until?: string;
silence_reason?: string;
escalate_level?: string;
escalate_to?: string;
root_cause?: string;
solution?: string;
metadata?: Record<string, any>;
}) => request.post("/Alert/v1/process/create", data);
/** 获取 告警处理记录列表 */
export const fetchAlertProcessList = (data: {
alert_record_id?: number,
action?: string,
page?: number,
page_size?: number,
keyword?: string,
sort?: string,
order?: string
}) => request.get("/Alert/v1/process/list", { params: data });

View File

@@ -33,6 +33,8 @@
/>
</a-form-item>
</a-col>
<!-- 自定义表单项插槽 -->
<slot name="form-items" />
</a-row>
</a-form>
</a-col>

View File

@@ -11,7 +11,11 @@
@update:model-value="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
/>
>
<template #form-items>
<slot name="form-items" />
</template>
</SearchForm>
<a-divider style="margin-top: 0" />

View File

@@ -0,0 +1,264 @@
<template>
<a-modal
:visible="visible"
title="告警记录详情"
width="900px"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:footer="false"
>
<a-spin :loading="loading" style="width: 100%">
<a-descriptions :column="2" bordered v-if="recordDetail">
<!-- 基础信息 -->
<a-descriptions-item label="告警名称" :span="2">
{{ recordDetail.alert_name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(recordDetail.status)">
{{ getStatusText(recordDetail.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="告警级别">
<div style="display: flex; align-items: center; gap: 8px;">
<div
v-if="recordDetail.severity"
:style="{
width: '20px',
height: '20px',
backgroundColor: recordDetail.severity.color,
border: '1px solid var(--color-border-2)',
borderRadius: '4px'
}"
></div>
<span>{{ recordDetail.severity?.name || '-' }}</span>
</div>
</a-descriptions-item>
<a-descriptions-item label="摘要" :span="2">
{{ recordDetail.summary || '-' }}
</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">
{{ recordDetail.description || '-' }}
</a-descriptions-item>
<!-- 时间信息 -->
<a-descriptions-item label="开始时间">
{{ formatDate(recordDetail.starts_at) }}
</a-descriptions-item>
<a-descriptions-item label="结束时间">
{{ formatDate(recordDetail.ends_at) || '-' }}
</a-descriptions-item>
<a-descriptions-item label="持续时长" :span="2">
{{ formatDuration(recordDetail.duration) }}
</a-descriptions-item>
<!-- 处理信息 -->
<a-descriptions-item label="处理状态" :span="2">
<a-tag v-if="recordDetail.process_status" :color="getProcessStatusColor(recordDetail.process_status)">
{{ getProcessStatusText(recordDetail.process_status) }}
</a-tag>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="处理人">
{{ recordDetail.processed_by || '-' }}
</a-descriptions-item>
<a-descriptions-item label="处理时间">
{{ formatDate(recordDetail.processed_at) || '-' }}
</a-descriptions-item>
<!-- 数值信息 -->
<a-descriptions-item label="当前值">
{{ recordDetail.value || '-' }}
</a-descriptions-item>
<a-descriptions-item label="阈值">
{{ recordDetail.threshold || '-' }}
</a-descriptions-item>
<!-- 通知信息 -->
<a-descriptions-item label="通知次数">
{{ recordDetail.notify_count || 0 }}
</a-descriptions-item>
<a-descriptions-item label="通知状态">
{{ recordDetail.notify_status || '-' }}
</a-descriptions-item>
<!-- 关联信息 -->
<a-descriptions-item label="策略ID">
{{ recordDetail.policy_id || '-' }}
</a-descriptions-item>
<a-descriptions-item label="规则ID">
{{ recordDetail.rule_id || '-' }}
</a-descriptions-item>
<!-- 标签信息 -->
<a-descriptions-item label="标签" :span="2">
<a-space wrap v-if="recordDetail.labels && Object.keys(recordDetail.labels).length > 0">
<a-tag v-for="(value, key) in recordDetail.labels" :key="key" color="blue">
{{ key }}: {{ value }}
</a-tag>
</a-space>
<span v-else>-</span>
</a-descriptions-item>
<!-- 创建与更新时间 -->
<a-descriptions-item label="创建时间">
{{ formatDate(recordDetail.created_at) }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">
{{ formatDate(recordDetail.updated_at) }}
</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchHistoryDetail } from '@/api/ops/alertHistory'
interface Props {
visible: boolean
recordId?: number
}
interface Emits {
(e: 'update:visible', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const loading = ref(false)
const recordDetail = ref<any>(null)
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 格式化持续时长
const formatDuration = (duration: number) => {
if (!duration) return '-'
const hours = Math.floor(duration / 3600)
const minutes = Math.floor((duration % 3600) / 60)
const seconds = duration % 60
if (hours > 0) {
return `${hours}小时${minutes}分钟`
} else if (minutes > 0) {
return `${minutes}分钟${seconds}`
} else {
return `${seconds}`
}
}
// 获取状态颜色
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
pending: 'gray',
firing: 'red',
resolved: 'green',
silenced: 'blue',
suppressed: 'orange',
acked: 'purple',
}
return colorMap[status] || 'gray'
}
// 获取状态文本
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
pending: '待处理',
firing: '触发中',
resolved: '已解决',
silenced: '已屏蔽',
suppressed: '已抑制',
acked: '已确认',
}
return textMap[status] || status
}
// 获取处理状态颜色
const getProcessStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
pending: 'gray',
processing: 'blue',
completed: 'green',
failed: 'red',
}
return colorMap[status] || 'gray'
}
// 获取处理状态文本
const getProcessStatusText = (status: string) => {
const textMap: Record<string, string> = {
pending: '待处理',
processing: '处理中',
completed: '已完成',
failed: '失败',
}
return textMap[status] || status
}
// 加载告警记录详情
const loadRecordDetail = async () => {
if (!props.recordId) return
loading.value = true
try {
const res: any = await fetchHistoryDetail(props.recordId)
if (res.code === 0) {
recordDetail.value = res.details
} else {
Message.error(res.message || '获取告警记录详情失败')
}
} catch (error) {
console.error('获取告警记录详情失败:', error)
Message.error('获取告警记录详情失败')
} finally {
loading.value = false
}
}
// 监听对话框显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal && props.recordId) {
loadRecordDetail()
} else if (!newVal) {
recordDetail.value = null
}
}
)
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
</script>
<script lang="ts">
export default {
name: 'HistoryDetailDialog',
}
</script>
<style scoped lang="less">
// 样式可以根据需要添加
</style>

View File

@@ -0,0 +1,96 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
export const columns: TableColumnData[] = [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '告警名称',
dataIndex: 'alert_name',
width: 200,
ellipsis: true,
tooltip: true,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
width: 100,
},
{
title: '告警级别',
dataIndex: 'severity',
slotName: 'severity',
width: 120,
},
{
title: '摘要',
dataIndex: 'summary',
width: 250,
ellipsis: true,
tooltip: true,
},
{
title: '开始时间',
dataIndex: 'starts_at',
slotName: 'starts_at',
width: 180,
},
{
title: '结束时间',
dataIndex: 'ends_at',
slotName: 'ends_at',
width: 180,
},
{
title: '持续时长',
dataIndex: 'duration',
slotName: 'duration',
width: 120,
},
{
title: '处理状态',
dataIndex: 'process_status',
slotName: 'process_status',
width: 100,
},
{
title: '处理人',
dataIndex: 'processed_by',
width: 120,
ellipsis: true,
},
{
title: '处理时间',
dataIndex: 'processed_at',
slotName: 'processed_at',
width: 180,
},
{
title: '当前值',
dataIndex: 'value',
width: 120,
ellipsis: true,
},
{
title: '阈值',
dataIndex: 'threshold',
width: 120,
ellipsis: true,
},
{
title: '通知次数',
dataIndex: 'notify_count',
width: 100,
},
{
title: '操作',
dataIndex: 'actions',
slotName: 'actions',
fixed: 'right',
width: 250,
},
]

View File

@@ -0,0 +1,38 @@
import type { FormItem } from '@/components/search-form/types'
export const searchFormConfig: FormItem[] = [
{
field: 'keyword',
label: '关键字',
type: 'input',
placeholder: '请输入告警名称、摘要或描述',
},
{
field: 'status',
label: '告警状态',
type: 'select',
placeholder: '请选择告警状态',
options: [
{ label: '待处理', value: 'pending' },
{ label: '触发中', value: 'firing' },
{ label: '已解决', value: 'resolved' },
{ label: '已屏蔽', value: 'silenced' },
{ label: '已抑制', value: 'suppressed' },
{ label: '已确认', value: 'acked' },
],
},
{
field: 'rule_id',
label: '规则',
type: 'select',
placeholder: '请选择规则',
options: [],
},
{
field: 'severity_id',
label: '告警级别',
type: 'select',
placeholder: '请选择告警级别',
options: [],
},
]

View File

@@ -0,0 +1,413 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="历史告警列表"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<!-- 时间范围选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-space>
<a-date-picker
v-model="formModel.starts_at"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="开始时间"
style="width: 180px;"
/>
<span>-</span>
<a-date-picker
v-model="formModel.ends_at"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="结束时间"
style="width: 180px;"
/>
</a-space>
</a-form-item>
</a-col>
</template>
<!-- 序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 状态 -->
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<!-- 告警级别 -->
<template #severity="{ record }">
<div v-if="record.severity" style="display: flex; align-items: center; gap: 8px;">
<div
:style="{
width: '16px',
height: '16px',
backgroundColor: record.severity.color,
border: '1px solid var(--color-border-2)',
borderRadius: '3px'
}"
></div>
<span>{{ record.severity.name }}</span>
</div>
<span v-else>-</span>
</template>
<!-- 开始时间 -->
<template #starts_at="{ record }">
{{ formatDate(record.starts_at) }}
</template>
<!-- 结束时间 -->
<template #ends_at="{ record }">
{{ formatDate(record.ends_at) || '-' }}
</template>
<!-- 持续时长 -->
<template #duration="{ record }">
{{ formatDuration(record.duration) }}
</template>
<!-- 处理状态 -->
<template #process_status="{ record }">
<a-tag v-if="record.process_status" :color="getProcessStatusColor(record.process_status)">
{{ getProcessStatusText(record.process_status) }}
</a-tag>
<span v-else>-</span>
</template>
<!-- 处理时间 -->
<template #processed_at="{ record }">
{{ formatDate(record.processed_at) || '-' }}
</template>
<!-- 操作 -->
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)">
详情
</a-button>
<a-button type="text" size="small" @click="handleViewProcess(record)">
处理记录
</a-button>
</template>
</search-table>
<!-- 告警记录详情对话框 -->
<history-detail-dialog
v-model:visible="detailVisible"
:record-id="currentRecordId"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } 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 { searchFormConfig } from './config/search-form'
import { columns as columnsConfig } from './config/columns'
import {
fetchHistories,
} from '@/api/ops/alertHistory'
import { fetchAlertLevelList } from '@/api/ops/alertLevel'
import HistoryDetailDialog from './components/HistoryDetailDialog.vue'
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
// 初始化表单模型设置默认时间范围最近7天
const getDefaultDateRange = () => {
const now = new Date()
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
const formatDate = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
return {
starts_at: formatDate(sevenDaysAgo),
ends_at: formatDate(now),
}
}
const formModel = ref({
keyword: '',
status: 'resolved', // 默认显示已解决的告警
rule_id: undefined,
severity_id: undefined,
...getDefaultDateRange(),
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 告警级别列表
const severityOptions = ref<any[]>([])
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键字',
type: 'input',
placeholder: '请输入告警名称、摘要或描述',
},
{
field: 'status',
label: '告警状态',
type: 'select',
placeholder: '请选择告警状态',
options: [
{ label: '待处理', value: 'pending' },
{ label: '触发中', value: 'firing' },
{ label: '已解决', value: 'resolved' },
{ label: '已屏蔽', value: 'silenced' },
{ label: '已抑制', value: 'suppressed' },
{ label: '已确认', value: 'acked' },
],
},
{
field: 'rule_id',
label: '规则',
type: 'select',
placeholder: '请选择规则',
options: [],
},
{
field: 'severity_id',
label: '告警级别',
type: 'select',
placeholder: '请选择告警级别',
options: severityOptions.value.map((s: any) => ({
label: s.name,
value: s.id,
})),
},
])
// 表格列配置
const columns = computed(() => columnsConfig)
// 当前选中的告警记录
const currentRecordId = ref<number | undefined>(undefined)
// 对话框可见性
const detailVisible = ref(false)
// 加载告警级别列表
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
}
} catch (error) {
console.error('获取告警级别列表失败:', error)
}
}
// 获取告警记录列表
const fetchHistoriesData = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
page_size: pagination.pageSize,
keyword: formModel.value.keyword || undefined,
status: formModel.value.status || undefined,
rule_id: formModel.value.rule_id || undefined,
severity_id: formModel.value.severity_id || undefined,
starts_at: formModel.value.starts_at || undefined,
ends_at: formModel.value.ends_at || undefined,
}
const res = await fetchHistories(params)
tableData.value = res.details?.data || []
pagination.total = res.details?.total || 0
} catch (error) {
console.error('获取告警记录列表失败:', error)
Message.error('获取告警记录列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 格式化持续时长
const formatDuration = (duration: number) => {
if (!duration) return '-'
const hours = Math.floor(duration / 3600)
const minutes = Math.floor((duration % 3600) / 60)
const seconds = duration % 60
if (hours > 0) {
return `${hours}小时${minutes}分钟`
} else if (minutes > 0) {
return `${minutes}分钟${seconds}`
} else {
return `${seconds}`
}
}
// 获取状态颜色
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
pending: 'gray',
firing: 'red',
resolved: 'green',
silenced: 'blue',
suppressed: 'orange',
acked: 'purple',
}
return colorMap[status] || 'gray'
}
// 获取状态文本
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
pending: '待处理',
firing: '触发中',
resolved: '已解决',
silenced: '已屏蔽',
suppressed: '已抑制',
acked: '已确认',
}
return textMap[status] || status
}
// 获取处理状态颜色
const getProcessStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
pending: 'gray',
processing: 'blue',
completed: 'green',
failed: 'red',
}
return colorMap[status] || 'gray'
}
// 获取处理状态文本
const getProcessStatusText = (status: string) => {
const textMap: Record<string, string> = {
pending: '待处理',
processing: '处理中',
completed: '已完成',
failed: '失败',
}
return textMap[status] || status
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchHistoriesData()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
status: 'resolved',
rule_id: undefined,
severity_id: undefined,
...getDefaultDateRange(),
}
pagination.current = 1
fetchHistoriesData()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchHistoriesData()
}
// 刷新
const handleRefresh = () => {
fetchHistoriesData()
Message.success('数据已刷新')
}
// 详情
const handleDetail = (record: any) => {
currentRecordId.value = record.id
detailVisible.value = true
}
// 查看处理记录
const handleViewProcess = (record: any) => {
Message.info(`查看告警 ${record.id} 的处理记录功能待实现`)
}
// 初始化加载数据
onMounted(async () => {
await loadSeverityOptions()
fetchHistoriesData()
})
</script>
<script lang="ts">
export default {
name: 'AlertHistoryList',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,485 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? `编辑告警策略 - ${policyName}` : '新建告警策略'"
width="800px"
:mask-closable="false"
@cancel="handleCancel"
@before-ok="handleSubmit"
@update:visible="handleVisibleChange"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
:style="{ marginBottom: '40px' }"
>
<!-- 策略基础信息 -->
<a-divider orientation="left">基础信息</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="name" label="策略名称" required>
<a-input
v-model="formData.name"
placeholder="请输入策略名称"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="priority" label="优先级">
<a-select
v-model="formData.priority"
placeholder="请选择优先级"
allow-clear
>
<a-option :value="1">1 (最高)</a-option>
<a-option :value="2">2</a-option>
<a-option :value="3">3</a-option>
<a-option :value="4">4</a-option>
<a-option :value="5">5 (最低)</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="description" label="策略描述">
<a-textarea
v-model="formData.description"
placeholder="请输入策略描述"
:auto-size="{ minRows: 2, maxRows: 4 }"
allow-clear
/>
</a-form-item>
<a-form-item field="enabled" label="启用状态">
<a-switch
v-model="formData.enabled"
:checked-value="true"
:unchecked-value="false"
>
<template #checked>
启用
</template>
<template #unchecked>
禁用
</template>
</a-switch>
</a-form-item>
<!-- 模板与标签 -->
<a-divider orientation="left">模板与标签</a-divider>
<a-form-item field="template_id" label="关联模板">
<a-select
v-model="formData.template_id"
placeholder="请选择告警模板(可选)"
allow-search
allow-clear
:loading="templateLoading"
>
<a-option
v-for="template in templateOptions"
:key="template.id"
:value="template.id"
>
{{ template.name }}
</a-option>
</a-select>
</a-form-item>
<a-form-item field="labels" label="标签">
<a-textarea
v-model="formData.labels"
placeholder='请输入标签JSON格式例如{"env":"prod","team":"ops"}'
:auto-size="{ minRows: 2, maxRows: 4 }"
allow-clear
/>
<template #extra>
请输入有效的 JSON 格式键值对
</template>
</a-form-item>
<!-- 自动建单与派单配置 -->
<a-divider orientation="left">自动建单与派单</a-divider>
<a-form-item field="auto_create_ticket" label="触发时自动建单">
<a-switch
v-model="formData.auto_create_ticket"
:checked-value="true"
:unchecked-value="false"
>
<template #checked>
开启
</template>
<template #unchecked>
关闭
</template>
</a-switch>
</a-form-item>
<a-form-item field="feedback_template_id" label="工单模板 ID">
<a-input-number
v-model="formData.feedback_template_id"
placeholder="请输入工单模板 ID"
:min="0"
allow-clear
:style="{ width: '100%' }"
/>
<template #extra>
0 表示不使用特定模板
</template>
</a-form-item>
<a-form-item label="派单规则">
<a-table
:data="dispatchRuleData"
:pagination="false"
:bordered="true"
size="small"
>
<template #columns>
<a-table-column title="告警级别" data-index="severity" :width="150">
<template #cell="{ record }">
<a-tag :color="record.color">
{{ record.name }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="处理人" data-index="assignee_id" :width="350">
<template #cell="{ record }">
<a-select
v-model="record.assignee_id"
placeholder="请选择处理人"
allow-search
allow-clear
:loading="userLoading"
:style="{ width: '100%' }"
>
<a-option
v-for="user in userOptions"
:key="user.id"
:value="user.id"
>
{{ user.name || user.username }} ({{ user.username }})
</a-option>
</a-select>
</template>
</a-table-column>
</template>
</a-table>
<div :style="{ marginTop: '16px' }">
<a-space>
<span :style="{ fontWeight: 'bold' }">默认处理人</span>
<a-select
v-model="defaultAssigneeId"
placeholder="请选择默认处理人"
allow-search
allow-clear
:loading="userLoading"
:style="{ width: '300px' }"
>
<a-option
v-for="user in userOptions"
:key="user.id"
:value="user.id"
>
{{ user.name || user.username }} ({{ user.username }})
</a-option>
</a-select>
</a-space>
</div>
</a-form-item>
</a-form>
<template #footer>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
保存
</a-button>
</template>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue'
import {
fetchPolicyDetail,
createPolicy,
updatePolicy,
fetchTemplateList,
fetchSeverityList,
} from '@/api/ops/alertPolicy'
// Props
interface Props {
visible: boolean
policyId?: number
}
const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'success'])
// 表单引用
const formRef = ref<FormInstance>()
// 加载状态
const submitLoading = ref(false)
const templateLoading = ref(false)
const userLoading = ref(false)
// 是否为编辑模式
const isEdit = computed(() => !!props.policyId)
const policyName = ref('')
// 表单数据
const formData = reactive({
name: '',
description: '',
enabled: true,
priority: 3,
labels: '',
template_id: undefined,
auto_create_ticket: false,
feedback_template_id: 0,
dispatch_rule: '',
})
// 表单验证规则
const formRules = {
name: [{ required: true, message: '请输入策略名称' }],
labels: [
{
validator: (value: string, callback: any) => {
if (!value) {
callback()
return
}
try {
JSON.parse(value)
callback()
} catch (error) {
callback('标签格式错误,请输入有效的 JSON 格式')
}
},
},
],
}
// 模板选项
const templateOptions = ref<any[]>([])
// 用户选项(模拟数据,实际应从用户服务获取)
const userOptions = ref<any[]>([
{ id: 1, name: '张三', username: 'zhangsan' },
{ id: 2, name: '李四', username: 'lisi' },
{ id: 3, name: '王五', username: 'wangwu' },
])
// 告警级别列表
const severityList = ref<any[]>([
{ code: 'critical', name: '严重', color: 'red' },
{ code: 'major', name: '重要', color: 'orange' },
{ code: 'warning', name: '警告', color: 'yellow' },
{ code: 'info', name: '信息', color: 'blue' },
{ code: 'minor', name: '次要', color: 'gray' },
])
// 派单规则数据
const dispatchRuleData = ref<any[]>([])
const defaultAssigneeId = ref<number | undefined>(undefined)
// 加载模板列表
const loadTemplateOptions = async () => {
templateLoading.value = true
try {
const res = await fetchTemplateList({ page: 1, page_size: 1000 })
if (res.code === 0 && res.details?.data) {
templateOptions.value = res.details.data
}
} catch (error) {
console.error('获取模板列表失败:', error)
} finally {
templateLoading.value = false
}
}
// 加载告警级别
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
}
} catch (error) {
console.error('获取告警级别失败:', error)
}
}
// 初始化派单规则数据
const initDispatchRuleData = () => {
dispatchRuleData.value = severityList.value.map((severity) => ({
severity_code: severity.code,
name: severity.name,
color: severity.color,
assignee_id: undefined,
}))
}
// 解析派单规则
const parseDispatchRule = (ruleStr: string) => {
try {
if (!ruleStr) return
const rule = JSON.parse(ruleStr)
if (rule.by_severity) {
dispatchRuleData.value.forEach((item) => {
if (rule.by_severity[item.severity_code] !== undefined) {
item.assignee_id = rule.by_severity[item.severity_code]
}
})
}
if (rule.default_assignee_id !== undefined) {
defaultAssigneeId.value = rule.default_assignee_id
}
} catch (error) {
console.error('解析派单规则失败:', error)
}
}
// 生成派单规则 JSON
const generateDispatchRule = (): string => {
const by_severity: any = {}
dispatchRuleData.value.forEach((item) => {
if (item.assignee_id !== undefined) {
by_severity[item.severity_code] = item.assignee_id
}
})
const rule: any = { by_severity }
if (defaultAssigneeId.value !== undefined) {
rule.default_assignee_id = defaultAssigneeId.value
}
return JSON.stringify(rule)
}
// 加载策略详情
const loadPolicyDetail = async () => {
if (!props.policyId) return
try {
const res = await fetchPolicyDetail(props.policyId)
if (res.code === 0 && res.details) {
const policy = res.details
policyName.value = policy.name
formData.name = policy.name
formData.description = policy.description || ''
formData.enabled = policy.enabled
formData.priority = policy.priority || 3
formData.labels = policy.labels || ''
formData.template_id = policy.template_id
formData.auto_create_ticket = policy.auto_create_ticket || false
formData.feedback_template_id = policy.feedback_template_id || 0
formData.dispatch_rule = policy.dispatch_rule || ''
// 解析派单规则
parseDispatchRule(policy.dispatch_rule || '')
}
} catch (error) {
console.error('获取策略详情失败:', error)
Message.error('获取策略详情失败')
}
}
// 重置表单
const resetForm = () => {
formData.name = ''
formData.description = ''
formData.enabled = true
formData.priority = 3
formData.labels = ''
formData.template_id = undefined
formData.auto_create_ticket = false
formData.feedback_template_id = 0
formData.dispatch_rule = ''
policyName.value = ''
defaultAssigneeId.value = undefined
initDispatchRuleData()
}
// 处理可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
resetForm()
}
// 提交
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
submitLoading.value = true
try {
// 生成派单规则
formData.dispatch_rule = generateDispatchRule()
const data = { ...formData }
// 编辑模式
if (isEdit.value && props.policyId) {
await updatePolicy({ id: props.policyId, ...data })
Message.success('策略更新成功')
} else {
// 新建模式
await createPolicy(data)
Message.success('策略创建成功')
}
emit('success')
handleCancel()
} catch (error: any) {
console.error('保存策略失败:', error)
Message.error(error.message || '保存策略失败')
} finally {
submitLoading.value = false
}
}
// 监听 visible 变化
watch(() => props.visible, (visible) => {
if (visible) {
if (isEdit.value) {
loadPolicyDetail()
} else {
resetForm()
}
} else {
resetForm()
}
})
// 初始化
loadTemplateOptions()
loadSeverityList()
initDispatchRuleData()
</script>
<script lang="ts">
export default {
name: 'PolicyFormDialog',
}
</script>

View File

@@ -0,0 +1,420 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑告警规则' : '新建告警规则'"
width="800px"
:mask-closable="false"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
:style="{ marginBottom: '40px' }"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="name" label="规则名称" required>
<a-input
v-model="formData.name"
placeholder="请输入规则名称"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="rule_type" label="规则类型" required>
<a-select
v-model="formData.rule_type"
placeholder="请选择规则类型"
@change="handleRuleTypeChange"
>
<a-option value="static">静态规则</a-option>
<a-option value="dynamic">动态规则</a-option>
<a-option value="promql">PromQL</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="enabled" label="启用状态">
<a-switch
v-model="formData.enabled"
:checked-value="true"
:unchecked-value="false"
>
<template #checked>
启用
</template>
<template #unchecked>
禁用
</template>
</a-switch>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="severity_id" label="告警级别" required>
<a-select
v-model="formData.severity_id"
placeholder="请选择告警级别"
:loading="severityLoading"
>
<a-option
v-for="severity in severityOptions"
:key="severity.id"
:value="severity.id"
>
<a-tag :color="severity.color">{{ severity.name }}</a-tag>
</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<!-- 指标/表达式 -->
<a-form-item
v-if="formData.rule_type === 'static'"
field="metric_name"
label="指标名称"
required
>
<a-input
v-model="formData.metric_name"
placeholder="请输入指标名称cpu_usage"
allow-clear
/>
</a-form-item>
<a-form-item
v-if="formData.rule_type === 'dynamic'"
field="metric_name"
label="指标名称"
required
>
<a-input
v-model="formData.metric_name"
placeholder="请输入动态指标名称cpu_usage_{host}"
allow-clear
/>
</a-form-item>
<a-form-item
v-if="formData.rule_type === 'promql'"
field="query_expr"
label="PromQL 表达式"
required
>
<a-textarea
v-model="formData.query_expr"
placeholder="请输入 PromQL 表达式rate(http_requests_total[5m])"
:auto-size="{ minRows: 2, maxRows: 4 }"
allow-clear
/>
</a-form-item>
<!-- 阈值条件 -->
<a-row v-if="formData.rule_type !== 'promql'" :gutter="16">
<a-col :span="6">
<a-form-item field="compare_op" label="比较操作符" required>
<a-select v-model="formData.compare_op" placeholder="请选择">
<a-option value=">">></a-option>
<a-option value="<"><</a-option>
<a-option value=">=">&ge;</a-option>
<a-option value="<=">&le;</a-option>
<a-option value="==">=</a-option>
<a-option value="!=">&ne;</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item field="threshold" label="阈值" required>
<a-input-number
v-model="formData.threshold"
placeholder="请输入阈值"
:precision="2"
allow-clear
:style="{ width: '100%' }"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="duration" label="持续时间(秒)">
<a-input-number
v-model="formData.duration"
placeholder="请输入持续时间"
:min="0"
:precision="0"
allow-clear
:style="{ width: '100%' }"
/>
<template #extra>
持续时间达到设定值才会触发告警
</template>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="description" label="规则描述">
<a-textarea
v-model="formData.description"
placeholder="请输入规则描述"
:auto-size="{ minRows: 2, maxRows: 4 }"
allow-clear
/>
</a-form-item>
</a-form>
<template #footer>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
保存
</a-button>
</template>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue'
import {
fetchRuleDetail,
createRule,
updateRule,
fetchSeverityList,
} from '@/api/ops/alertPolicy'
// Props
interface Props {
visible: boolean
ruleId?: number
policyId?: number
}
const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'success'])
// 表单引用
const formRef = ref<FormInstance>()
// 加载状态
const submitLoading = ref(false)
const severityLoading = ref(false)
// 是否为编辑模式
const isEdit = computed(() => !!props.ruleId)
// 表单数据
const formData = reactive({
name: '',
rule_type: 'static',
enabled: true,
severity_id: 0 as number,
metric_name: '',
query_expr: '',
threshold: undefined as number | undefined,
compare_op: '>',
duration: 60,
description: '',
})
// 表单验证规则
const formRules = {
name: [{ required: true, message: '请输入规则名称' }],
rule_type: [{ required: true, message: '请选择规则类型' }],
severity_id: [{ required: true, message: '请选择告警级别' }],
metric_name: [
{
validator: (value: string, callback: any) => {
if (formData.rule_type !== 'promql' && !value) {
callback('请输入指标名称')
return
}
callback()
},
},
],
query_expr: [
{
validator: (value: string, callback: any) => {
if (formData.rule_type === 'promql' && !value) {
callback('请输入 PromQL 表达式')
return
}
callback()
},
},
],
compare_op: [
{
validator: (value: string, callback: any) => {
if (formData.rule_type !== 'promql' && !value) {
callback('请选择比较操作符')
return
}
callback()
},
},
],
threshold: [
{
validator: (value: number, callback: any) => {
if (formData.rule_type !== 'promql' && value === undefined) {
callback('请输入阈值')
return
}
callback()
},
},
],
}
// 告警级别选项
const severityOptions = ref<any[]>([])
// 加载告警级别列表
const loadSeverityOptions = async () => {
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
}
} catch (error) {
console.error('获取告警级别失败:', error)
} finally {
severityLoading.value = false
}
}
// 规则类型变化处理
const handleRuleTypeChange = (value: string) => {
// 根据规则类型清空相关字段
if (value === 'promql') {
formData.metric_name = ''
formData.compare_op = ''
formData.threshold = undefined
} else {
formData.query_expr = ''
if (!formData.compare_op) {
formData.compare_op = '>'
}
}
}
// 加载规则详情
const loadRuleDetail = async () => {
if (!props.ruleId) return
try {
const res = await fetchRuleDetail(props.ruleId)
if (res.code === 0 && res.details) {
const rule = res.details
formData.name = rule.name
formData.rule_type = rule.rule_type || 'static'
formData.enabled = rule.enabled
formData.severity_id = rule.severity_id
formData.metric_name = rule.metric_name || ''
formData.query_expr = rule.query_expr || ''
formData.threshold = rule.threshold
formData.compare_op = rule.compare_op || '>'
formData.duration = rule.duration || 60
formData.description = rule.description || ''
}
} catch (error) {
console.error('获取规则详情失败:', error)
Message.error('获取规则详情失败')
}
}
// 重置表单
const resetForm = () => {
formData.name = ''
formData.rule_type = 'static'
formData.enabled = true
formData.severity_id = 0
formData.metric_name = ''
formData.query_expr = ''
formData.threshold = undefined
formData.compare_op = '>'
formData.duration = 60
formData.description = ''
}
// 处理可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
resetForm()
}
// 提交
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
if (!props.policyId) {
Message.warning('缺少策略 ID')
return
}
submitLoading.value = true
try {
const data = {
...formData,
policy_id: props.policyId,
severity_id: formData.severity_id, // 确保 severity_id 是 number 类型
}
// 编辑模式
if (isEdit.value && props.ruleId) {
await updateRule({ id: props.ruleId, ...data })
Message.success('规则更新成功')
} else {
// 新建模式
await createRule(data)
Message.success('规则创建成功')
}
emit('success')
handleCancel()
} catch (error: any) {
console.error('保存规则失败:', error)
Message.error(error.message || '保存规则失败')
} finally {
submitLoading.value = false
}
}
// 监听 visible 变化
watch(() => props.visible, (visible) => {
if (visible) {
if (isEdit.value) {
loadRuleDetail()
} else {
resetForm()
}
} else {
resetForm()
}
})
// 初始化
loadSeverityOptions()
</script>
<script lang="ts">
export default {
name: 'RuleFormDialog',
}
</script>

View File

@@ -0,0 +1,311 @@
<template>
<a-modal
:visible="visible"
:title="`规则管理 - ${policyName}`"
width="1200px"
:mask-closable="false"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
>
<div class="rule-manage-content">
<!-- 工具栏 -->
<div class="toolbar">
<a-input-search
v-model="searchKeyword"
placeholder="请输入规则名称搜索"
style="width: 300px"
allow-clear
@search="handleSearch"
/>
<a-button type="primary" @click="handleCreateRule">
<template #icon>
<icon-plus />
</template>
新建规则
</a-button>
</div>
<!-- 规则列表 -->
<a-table
:data="ruleData"
:loading="ruleLoading"
:pagination="rulePagination"
:bordered="true"
size="small"
@page-change="handlePageChange"
>
<template #columns>
<a-table-column title="规则名称" data-index="name" :width="200" />
<a-table-column title="规则类型" data-index="rule_type" :width="100">
<template #cell="{ record }">
<a-tag>{{ getRuleTypeText(record.rule_type) }}</a-tag>
</template>
</a-table-column>
<a-table-column title="启用状态" data-index="enabled" :width="100">
<template #cell="{ record }">
<a-tag :color="record.enabled ? 'green' : 'gray'">
{{ record.enabled ? '启用' : '禁用' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="指标/表达式" data-index="metric_expr" :width="200">
<template #cell="{ record }">
<span :title="record.metric_name || record.query_expr">
{{ record.metric_name || record.query_expr || '-' }}
</span>
</template>
</a-table-column>
<a-table-column title="阈值条件" data-index="threshold" :width="120">
<template #cell="{ record }">
<span v-if="record.threshold !== undefined">
{{ getCompareOpText(record.compare_op) }} {{ record.threshold }}
</span>
<span v-else>-</span>
</template>
</a-table-column>
<a-table-column title="持续时间" data-index="duration" :width="100">
<template #cell="{ record }">
<span v-if="record.duration">{{ formatDuration(record.duration) }}</span>
<span v-else>-</span>
</template>
</a-table-column>
<a-table-column title="告警级别" data-index="severity" :width="120">
<template #cell="{ record }">
<div v-if="record.severity" :style="{ display: 'flex', alignItems: 'center', gap: '8px' }">
<div
:style="{
width: '16px',
height: '16px',
backgroundColor: record.severity.color,
border: '1px solid var(--color-border-2)',
borderRadius: '3px'
}"
></div>
<span>{{ record.severity.name }}</span>
</div>
<span v-else>-</span>
</template>
</a-table-column>
<a-table-column title="操作" data-index="actions" :width="150" fixed="right">
<template #cell="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleEditRule(record)">
编辑
</a-button>
<a-popconfirm
content="确定要删除该规则吗?"
@ok="handleDeleteRule(record)"
>
<a-button type="text" size="small" status="danger">
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</div>
<!-- 新建/编辑规则弹窗 -->
<rule-form-dialog
v-model:visible="ruleFormVisible"
:rule-id="currentRuleId"
:policy-id="policyId"
@success="handleRuleFormSuccess"
/>
</a-modal>
</template>
<script lang="ts" setup>
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 RuleFormDialog from './RuleFormDialog.vue'
// Props
interface Props {
visible: boolean
policyId?: number
policyName?: string
}
const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'success'])
// 状态管理
const ruleLoading = ref(false)
const ruleData = ref<any[]>([])
const searchKeyword = ref('')
// 规则分页
const rulePagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
// 规则表单
const ruleFormVisible = ref(false)
const currentRuleId = ref<number | undefined>(undefined)
// 规则类型映射
const ruleTypeMap: Record<string, string> = {
static: '静态规则',
dynamic: '动态规则',
promql: 'PromQL',
}
// 比较操作符映射
const compareOpMap: Record<string, string> = {
'>': '>',
'<': '<',
'>=': '≥',
'<=': '≤',
'==': '=',
'!=': '≠',
}
// 获取规则类型文本
const getRuleTypeText = (type: string) => {
return ruleTypeMap[type] || type
}
// 获取比较操作符文本
const getCompareOpText = (op: string) => {
return compareOpMap[op] || op
}
// 格式化持续时间
const formatDuration = (seconds: number) => {
if (seconds < 60) {
return `${seconds}`
} else if (seconds < 3600) {
return `${Math.floor(seconds / 60)}分钟`
} else {
return `${Math.floor(seconds / 3600)}小时`
}
}
// 加载规则列表
const loadRuleList = async () => {
if (!props.policyId) return
ruleLoading.value = true
try {
const res = await fetchRuleList({
policy_id: props.policyId,
page: rulePagination.current,
page_size: rulePagination.pageSize,
keyword: searchKeyword.value || undefined,
})
ruleData.value = res.details?.data || []
rulePagination.total = res.details?.total || 0
} catch (error) {
console.error('获取规则列表失败:', error)
Message.error('获取规则列表失败')
ruleData.value = []
rulePagination.total = 0
} finally {
ruleLoading.value = false
}
}
// 搜索
const handleSearch = () => {
rulePagination.current = 1
loadRuleList()
}
// 分页变化
const handlePageChange = (current: number) => {
rulePagination.current = current
loadRuleList()
}
// 新建规则
const handleCreateRule = () => {
if (!props.policyId) {
Message.warning('请先选择策略')
return
}
currentRuleId.value = undefined
ruleFormVisible.value = true
}
// 编辑规则
const handleEditRule = (record: any) => {
currentRuleId.value = record.id
ruleFormVisible.value = true
}
// 删除规则
const handleDeleteRule = async (record: any) => {
try {
await deleteRule(record.id)
Message.success('规则删除成功')
// 如果当前页只有一条数据,且不是第一页,则跳到上一页
if (ruleData.value.length === 1 && rulePagination.current > 1) {
rulePagination.current--
}
loadRuleList()
} catch (error) {
console.error('删除规则失败:', error)
Message.error('删除规则失败')
}
}
// 规则表单成功回调
const handleRuleFormSuccess = () => {
loadRuleList()
emit('success')
}
// 处理可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 监听 visible 变化
watch(() => props.visible, (visible) => {
if (visible) {
searchKeyword.value = ''
rulePagination.current = 1
loadRuleList()
}
})
</script>
<script lang="ts">
export default {
name: 'RuleManageDialog',
}
</script>
<style scoped lang="less">
.rule-manage-content {
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,79 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
export const columns: TableColumnData[] = [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '策略名称',
dataIndex: 'name',
slotName: 'name',
width: 200,
ellipsis: true,
tooltip: true,
},
{
title: '策略描述',
dataIndex: 'description',
width: 250,
ellipsis: true,
tooltip: true,
},
{
title: '启用状态',
dataIndex: 'enabled',
slotName: 'enabled',
width: 100,
},
{
title: '优先级',
dataIndex: 'priority',
width: 80,
},
{
title: '规则数',
dataIndex: 'rule_count',
slotName: 'rule_count',
width: 80,
},
{
title: '通知渠道数',
dataIndex: 'channel_count',
slotName: 'channel_count',
width: 100,
},
{
title: '抑制规则数',
dataIndex: 'suppression_rule_count',
slotName: 'suppression_rule_count',
width: 100,
},
{
title: '自动建单',
dataIndex: 'auto_create_ticket',
slotName: 'auto_create_ticket',
width: 100,
},
{
title: '创建时间',
dataIndex: 'created_at',
slotName: 'created_at',
width: 180,
},
{
title: '最后修改',
dataIndex: 'updated_at',
slotName: 'updated_at',
width: 180,
},
{
title: '操作',
dataIndex: 'actions',
slotName: 'actions',
fixed: 'right',
width: 300,
},
]

View File

@@ -0,0 +1,34 @@
import type { FormItem } from '@/components/search-form/types'
export const searchFormConfig: FormItem[] = [
{
field: 'keyword',
label: '关键字',
type: 'input',
placeholder: '请输入策略名称或描述',
},
{
field: 'enabled',
label: '启用状态',
type: 'select',
placeholder: '请选择启用状态',
options: [
{ label: '全部', value: 'all' },
{ label: '已启用', value: 'true' },
{ label: '已禁用', value: 'false' },
],
},
{
field: 'priority',
label: '优先级',
type: 'select',
placeholder: '请选择优先级',
options: [
{ label: '1 (最高)', value: 1 },
{ label: '2', value: 2 },
{ label: '3', value: 3 },
{ label: '4', value: 4 },
{ label: '5 (最低)', value: 5 },
],
},
]

View File

@@ -0,0 +1,336 @@
<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="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<!-- 序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 策略名称 -->
<template #name="{ record }">
<a-space>
<a-tag v-if="!record.enabled" color="gray">禁用</a-tag>
<span>{{ record.name }}</span>
</a-space>
</template>
<!-- 启用状态 -->
<template #enabled="{ record }">
<a-switch
:model-value="record.enabled"
:checked-value="true"
:unchecked-value="false"
@change="handleToggleEnabled(record)"
/>
</template>
<!-- 规则数 -->
<template #rule_count="{ record }">
<span>{{ record.rules?.length || 0 }} </span>
</template>
<!-- 通知渠道数 -->
<template #channel_count="{ record }">
<span>{{ record.channels?.length || 0 }} </span>
</template>
<!-- 抑制规则数 -->
<template #suppression_rule_count="{ record }">
<span>{{ record.suppression_rules?.length || 0 }} </span>
</template>
<!-- 自动建单 -->
<template #auto_create_ticket="{ record }">
<a-tag :color="record.auto_create_ticket ? 'green' : 'gray'">
{{ record.auto_create_ticket ? '是' : '否' }}
</a-tag>
</template>
<!-- 创建时间 -->
<template #created_at="{ record }">
{{ formatDate(record.created_at) }}
</template>
<!-- 最后修改 -->
<template #updated_at="{ record }">
{{ formatDate(record.updated_at) }}
</template>
<!-- 操作 -->
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="text" size="small" @click="handleManageRules(record)">
规则管理
</a-button>
<a-popconfirm
content="确定要删除该策略吗?删除后将无法恢复,且会影响告警评估。"
@ok="handleDelete(record)"
>
<a-button type="text" size="small" status="danger">
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
<!-- 额外操作按钮 -->
<template #extra>
<a-button type="primary" @click="handleCreate">
<template #icon>
<icon-plus />
</template>
新建告警策略
</a-button>
</template>
</search-table>
<!-- 新建/编辑策略弹窗 -->
<policy-form-dialog
v-model:visible="policyFormVisible"
:policy-id="currentPolicyId"
@success="handlePolicyFormSuccess"
/>
<!-- 规则管理弹窗 -->
<rule-manage-dialog
v-model:visible="ruleManageVisible"
:policy-id="currentPolicyId"
:policy-name="currentPolicyName"
@success="handleRuleManageSuccess"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } 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 { searchFormConfig } from './config/search-form'
import { columns as columnsConfig } from './config/columns'
import {
fetchPolicyList,
deletePolicy,
updatePolicy,
} from '@/api/ops/alertPolicy'
import PolicyFormDialog from './components/PolicyFormDialog.vue'
import RuleManageDialog from './components/RuleManageDialog.vue'
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
const formModel = ref({
keyword: '',
enabled: 'all',
priority: undefined,
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => searchFormConfig)
// 表格列配置
const columns = computed(() => columnsConfig)
// 当前选中的策略
const currentPolicyId = ref<number | undefined>(undefined)
const currentPolicyName = ref('')
// 弹窗可见性
const policyFormVisible = ref(false)
const ruleManageVisible = ref(false)
// 获取策略列表
const fetchPolicyData = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
page_size: pagination.pageSize,
keyword: formModel.value.keyword || undefined,
order_by: 'created_at',
order: 'desc',
}
// 处理启用状态筛选
if (formModel.value.enabled && formModel.value.enabled !== 'all') {
params.enabled = formModel.value.enabled
}
// 处理优先级筛选
if (formModel.value.priority) {
params.priority = [formModel.value.priority]
}
const res = await fetchPolicyList(params)
tableData.value = res.details?.data || []
pagination.total = res.details?.total || 0
} catch (error) {
console.error('获取告警策略列表失败:', error)
Message.error('获取告警策略列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchPolicyData()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
enabled: 'all',
priority: undefined,
}
pagination.current = 1
fetchPolicyData()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchPolicyData()
}
// 刷新
const handleRefresh = () => {
fetchPolicyData()
Message.success('数据已刷新')
}
// 新建策略
const handleCreate = () => {
currentPolicyId.value = undefined
policyFormVisible.value = true
}
// 编辑策略
const handleEdit = (record: any) => {
currentPolicyId.value = record.id
policyFormVisible.value = true
}
// 切换启用状态
const handleToggleEnabled = async (record: any) => {
try {
const newStatus = !record.enabled
await updatePolicy({
id: record.id,
enabled: newStatus,
})
Message.success(newStatus ? '策略已启用' : '策略已禁用')
record.enabled = newStatus
} catch (error) {
console.error('更新策略状态失败:', error)
Message.error('更新策略状态失败')
}
}
// 删除策略
const handleDelete = async (record: any) => {
try {
await deletePolicy(record.id)
Message.success('策略删除成功')
// 如果当前页只有一条数据,且不是第一页,则跳到上一页
if (tableData.value.length === 1 && pagination.current > 1) {
pagination.current--
}
fetchPolicyData()
} catch (error) {
console.error('删除策略失败:', error)
Message.error('删除策略失败')
}
}
// 规则管理
const handleManageRules = (record: any) => {
currentPolicyId.value = record.id
currentPolicyName.value = record.name
ruleManageVisible.value = true
}
// 策略表单成功回调
const handlePolicyFormSuccess = () => {
fetchPolicyData()
}
// 规则管理成功回调
const handleRuleManageSuccess = () => {
fetchPolicyData()
}
// 初始化加载数据
onMounted(() => {
fetchPolicyData()
})
</script>
<script lang="ts">
export default {
name: 'AlertPolicySetting',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<a-modal
:visible="visible"
title="确认告警"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleCancel"
:confirm-loading="loading"
width="600px"
>
<a-form :model="form" layout="vertical">
<a-form-item label="告警ID">
<a-input v-model="alertId" disabled />
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-model="form.comment"
placeholder="请输入备注信息(可选)"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createAlertProcess } from '@/api/ops/alertRecord'
interface Props {
visible: boolean
alertRecordId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': []
}>()
const loading = ref(false)
const form = ref({
comment: '',
})
const alertId = ref('')
watch(() => props.visible, (val) => {
if (val) {
alertId.value = String(props.alertRecordId)
form.value.comment = ''
}
})
const handleOk = async () => {
loading.value = true
try {
await createAlertProcess({
alert_record_id: props.alertRecordId,
action: 'ack',
operator: getCurrentUser(),
comment: form.value.comment,
})
Message.success('确认成功')
emit('success')
emit('update:visible', false)
} catch (error) {
console.error('确认失败:', error)
Message.error('确认失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('update:visible', false)
}
function getCurrentUser() {
// TODO: 从全局状态获取当前用户
return 'admin'
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<a-modal
:visible="visible"
title="添加评论"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleCancel"
:confirm-loading="loading"
width="600px"
>
<a-form :model="form" layout="vertical">
<a-form-item label="告警ID">
<a-input v-model="alertId" disabled />
</a-form-item>
<a-form-item label="评论内容" required>
<a-textarea
v-model="form.comment"
placeholder="请输入评论内容"
:auto-size="{ minRows: 4, maxRows: 8 }"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createAlertProcess } from '@/api/ops/alertRecord'
interface Props {
visible: boolean
alertRecordId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': []
}>()
const loading = ref(false)
const form = ref({
comment: '',
})
const alertId = ref('')
watch(() => props.visible, (val) => {
if (val) {
alertId.value = String(props.alertRecordId)
form.value.comment = ''
}
})
const handleOk = async () => {
if (!form.value.comment) {
Message.error('请输入评论内容')
return
}
loading.value = true
try {
await createAlertProcess({
alert_record_id: props.alertRecordId,
action: 'comment',
operator: getCurrentUser(),
comment: form.value.comment,
})
Message.success('评论添加成功')
emit('success')
emit('update:visible', false)
} catch (error) {
console.error('添加评论失败:', error)
Message.error('添加评论失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('update:visible', false)
}
function getCurrentUser() {
// TODO: 从全局状态获取当前用户
return 'admin'
}
</script>

View File

@@ -0,0 +1,329 @@
<template>
<a-modal
:visible="visible"
title="告警详情"
:footer="false"
width="1000px"
@cancel="handleCancel"
@update:visible="handleCancel"
>
<a-spin :loading="loading">
<div v-if="record" class="alert-detail">
<!-- 基础信息 -->
<a-descriptions title="基础信息" :column="2" bordered>
<a-descriptions-item label="告警ID">{{ record.id }}</a-descriptions-item>
<a-descriptions-item label="告警名称">{{ record.alert_name }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="告警级别">
<a-tag v-if="record.severity" :color="record.severity.color">
{{ record.severity.name || record.severity.code }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="策略ID">{{ record.policy_id }}</a-descriptions-item>
<a-descriptions-item label="规则ID">{{ record.rule_id }}</a-descriptions-item>
</a-descriptions>
<!-- 告警内容 -->
<a-descriptions title="告警内容" :column="1" bordered class="mt-4">
<a-descriptions-item label="摘要">
{{ record.summary || '-' }}
</a-descriptions-item>
<a-descriptions-item label="描述">
{{ record.description || '-' }}
</a-descriptions-item>
<a-descriptions-item label="当前值">
{{ record.value || '-' }}
</a-descriptions-item>
<a-descriptions-item label="阈值">
{{ record.threshold || '-' }}
</a-descriptions-item>
</a-descriptions>
<!-- 时间信息 -->
<a-descriptions title="时间信息" :column="2" bordered class="mt-4">
<a-descriptions-item label="开始时间">
{{ formatDateTime(record.starts_at) }}
</a-descriptions-item>
<a-descriptions-item label="结束时间">
{{ record.ends_at ? formatDateTime(record.ends_at) : '-' }}
</a-descriptions-item>
<a-descriptions-item label="持续时长">
{{ formatDuration(record.duration) }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatDateTime(record.created_at) }}
</a-descriptions-item>
</a-descriptions>
<!-- 处理信息 -->
<a-descriptions title="处理信息" :column="2" bordered class="mt-4">
<a-descriptions-item label="处理状态">
{{ record.process_status || '-' }}
</a-descriptions-item>
<a-descriptions-item label="处理人">
{{ record.processed_by || '-' }}
</a-descriptions-item>
<a-descriptions-item label="处理时间">
{{ record.processed_at ? formatDateTime(record.processed_at) : '-' }}
</a-descriptions-item>
<a-descriptions-item label="关联工单">
{{ record.feedback_ticket_id || '-' }}
</a-descriptions-item>
</a-descriptions>
<!-- 通知信息 -->
<a-descriptions title="通知信息" :column="2" bordered class="mt-4">
<a-descriptions-item label="通知次数">
{{ record.notify_count || 0 }}
</a-descriptions-item>
<a-descriptions-item label="通知状态">
<a-tag :color="getNotifyStatusColor(record.notify_status)">
{{ getNotifyStatusText(record.notify_status) }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
<!-- 标签 -->
<a-descriptions title="标签" :column="1" bordered class="mt-4">
<a-descriptions-item label="Labels">
<div v-if="labels">
<a-tag v-for="(value, key) in labels" :key="key" class="mb-1">
{{ key }}: {{ value }}
</a-tag>
</div>
<span v-else>-</span>
</a-descriptions-item>
</a-descriptions>
<!-- 注解 -->
<a-descriptions title="注解" :column="1" bordered class="mt-4">
<a-descriptions-item label="Annotations">
<div v-if="annotations">
<a-tag v-for="(value, key) in annotations" :key="key" class="mb-1">
{{ key }}: {{ value }}
</a-tag>
</div>
<span v-else>-</span>
</a-descriptions-item>
</a-descriptions>
<!-- 处理记录 -->
<div class="mt-6">
<div class="mb-2 font-bold">处理记录</div>
<a-table
:data="processRecords"
:columns="processColumns"
:pagination="false"
size="small"
>
<template #action="{ record }">
<a-tag :color="getActionColor(record.action)">
{{ getActionText(record.action) }}
</a-tag>
</template>
</a-table>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchAlertRecordDetail, fetchAlertProcessList } from '@/api/ops/alertRecord'
interface Props {
visible: boolean
alertRecordId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
const loading = ref(false)
const record = ref<any>(null)
const processRecords = ref<any[]>([])
const labels = computed(() => {
if (!record.value?.labels) return null
try {
return JSON.parse(record.value.labels)
} catch {
return null
}
})
const annotations = computed(() => {
if (!record.value?.annotations) return null
try {
return JSON.parse(record.value.annotations)
} catch {
return null
}
})
const processColumns = [
{ title: '时间', dataIndex: 'created_at', slotName: 'created_at', width: 180 },
{ title: '操作', dataIndex: 'action', slotName: 'action', width: 100 },
{ title: '操作人', dataIndex: 'operator', width: 120 },
{ title: '备注', dataIndex: 'comment', ellipsis: true, tooltip: true },
]
watch(() => props.visible, (val) => {
if (val) {
loadDetail()
loadProcessRecords()
}
})
const loadDetail = async () => {
loading.value = true
try {
const data = await fetchAlertRecordDetail(props.alertRecordId)
record.value = data.details
} catch (error) {
console.error('加载详情失败:', error)
Message.error('加载详情失败')
} finally {
loading.value = false
}
}
const loadProcessRecords = async () => {
try {
const result = await fetchAlertProcessList({
alert_record_id: props.alertRecordId,
page: 1,
page_size: 100,
})
processRecords.value = result.details.data || []
} catch (error) {
console.error('加载处理记录失败:', error)
}
}
const handleCancel = () => {
emit('update:visible', false)
}
// 格式化函数
const formatDateTime = (datetime: string) => {
if (!datetime) return '-'
return new Date(datetime).toLocaleString('zh-CN')
}
const formatDuration = (seconds: number) => {
if (!seconds) return '-'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${hours}小时${minutes}分钟`
}
return `${minutes}分钟`
}
// 状态相关
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
firing: 'red',
pending: 'orange',
acked: 'gold',
resolved: 'green',
silenced: 'gray',
suppressed: 'lightgray',
}
return colorMap[status] || 'blue'
}
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
firing: '告警中',
pending: '待处理',
acked: '已确认',
resolved: '已解决',
silenced: '已屏蔽',
suppressed: '已抑制',
}
return textMap[status] || status
}
const getNotifyStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
success: 'green',
failed: 'red',
pending: 'orange',
}
return colorMap[status] || 'blue'
}
const getNotifyStatusText = (status: string) => {
const textMap: Record<string, string> = {
success: '成功',
failed: '失败',
pending: '待发送',
}
return textMap[status] || status
}
const getActionColor = (action: string) => {
const colorMap: Record<string, string> = {
ack: 'gold',
resolve: 'green',
silence: 'gray',
comment: 'blue',
assign: 'purple',
escalate: 'orange',
close: 'red',
}
return colorMap[action] || 'blue'
}
const getActionText = (action: string) => {
const textMap: Record<string, string> = {
ack: '确认',
resolve: '解决',
silence: '屏蔽',
comment: '评论',
assign: '分配',
escalate: '升级',
close: '关闭',
}
return textMap[action] || action
}
</script>
<style scoped>
.alert-detail {
max-height: 70vh;
overflow-y: auto;
}
.mt-4 {
margin-top: 16px;
}
.mt-6 {
margin-top: 24px;
}
.mb-1 {
margin-bottom: 4px;
}
.mb-2 {
margin-bottom: 8px;
}
.font-bold {
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<a-modal
:visible="visible"
title="解决告警"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleCancel"
:confirm-loading="loading"
width="600px"
>
<a-form :model="form" layout="vertical">
<a-form-item label="告警ID">
<a-input v-model="alertId" disabled />
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-model="form.comment"
placeholder="请输入备注信息(可选)"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
</a-form-item>
<a-form-item label="根本原因">
<a-textarea
v-model="form.root_cause"
placeholder="请输入根本原因(可选)"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
</a-form-item>
<a-form-item label="解决方案">
<a-textarea
v-model="form.solution"
placeholder="请输入解决方案(可选)"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createAlertProcess } from '@/api/ops/alertRecord'
interface Props {
visible: boolean
alertRecordId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': []
}>()
const loading = ref(false)
const form = ref({
comment: '',
root_cause: '',
solution: '',
})
const alertId = ref('')
watch(() => props.visible, (val) => {
if (val) {
alertId.value = String(props.alertRecordId)
form.value.comment = ''
form.value.root_cause = ''
form.value.solution = ''
}
})
const handleOk = async () => {
loading.value = true
try {
await createAlertProcess({
alert_record_id: props.alertRecordId,
action: 'resolve',
operator: getCurrentUser(),
comment: form.value.comment,
root_cause: form.value.root_cause,
solution: form.value.solution,
})
Message.success('解决成功')
emit('success')
emit('update:visible', false)
} catch (error) {
console.error('解决失败:', error)
Message.error('解决失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('update:visible', false)
}
function getCurrentUser() {
// TODO: 从全局状态获取当前用户
return 'admin'
}
</script>

View File

@@ -0,0 +1,116 @@
<template>
<a-modal
:visible="visible"
title="屏蔽告警"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleCancel"
:confirm-loading="loading"
width="600px"
>
<a-form :model="form" layout="vertical">
<a-form-item label="告警ID">
<a-input v-model="alertId" disabled />
</a-form-item>
<a-form-item label="屏蔽时长" required>
<a-select v-model="form.duration" placeholder="请选择屏蔽时长">
<a-option :value="3600">1小时</a-option>
<a-option :value="7200">2小时</a-option>
<a-option :value="14400">4小时</a-option>
<a-option :value="28800">8小时</a-option>
<a-option :value="86400">1</a-option>
<a-option :value="172800">2</a-option>
<a-option :value="604800">1</a-option>
</a-select>
</a-form-item>
<a-form-item label="屏蔽原因">
<a-textarea
v-model="form.silence_reason"
placeholder="请输入屏蔽原因"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-model="form.comment"
placeholder="请输入备注信息(可选)"
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createAlertProcess } from '@/api/ops/alertRecord'
interface Props {
visible: boolean
alertRecordId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': []
}>()
const loading = ref(false)
const form = ref({
duration: 3600,
silence_reason: '',
comment: '',
})
const alertId = ref('')
watch(() => props.visible, (val) => {
if (val) {
alertId.value = String(props.alertRecordId)
form.value.duration = 3600
form.value.silence_reason = ''
form.value.comment = ''
}
})
const handleOk = async () => {
if (!form.value.silence_reason) {
Message.error('请输入屏蔽原因')
return
}
loading.value = true
try {
const silenceUntil = new Date(Date.now() + form.value.duration * 1000).toISOString()
await createAlertProcess({
alert_record_id: props.alertRecordId,
action: 'silence',
operator: getCurrentUser(),
silence_until: silenceUntil,
silence_reason: form.value.silence_reason,
comment: form.value.comment,
})
Message.success('屏蔽成功')
emit('success')
emit('update:visible', false)
} catch (error) {
console.error('屏蔽失败:', error)
Message.error('屏蔽失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('update:visible', false)
}
function getCurrentUser() {
// TODO: 从全局状态获取当前用户
return 'admin'
}
</script>

View File

@@ -0,0 +1,103 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
export const columns: TableColumnData[] = [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '告警名称',
dataIndex: 'alert_name',
width: 200,
ellipsis: true,
tooltip: true,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
width: 100,
},
{
title: '告警级别',
dataIndex: 'severity',
slotName: 'severity',
width: 120,
},
{
title: '摘要',
dataIndex: 'summary',
width: 250,
ellipsis: true,
tooltip: true,
},
{
title: '当前值',
dataIndex: 'value',
width: 120,
ellipsis: true,
},
{
title: '阈值',
dataIndex: 'threshold',
width: 120,
ellipsis: true,
},
{
title: '开始时间',
dataIndex: 'starts_at',
slotName: 'starts_at',
width: 180,
},
{
title: '持续时长',
dataIndex: 'duration',
slotName: 'duration',
width: 120,
},
{
title: '处理状态',
dataIndex: 'process_status',
slotName: 'process_status',
width: 100,
},
{
title: '处理人',
dataIndex: 'processed_by',
width: 120,
ellipsis: true,
},
{
title: '处理时间',
dataIndex: 'processed_at',
slotName: 'processed_at',
width: 180,
},
{
title: '通知次数',
dataIndex: 'notify_count',
width: 100,
},
{
title: '通知状态',
dataIndex: 'notify_status',
slotName: 'notify_status',
width: 100,
},
{
title: '标签',
dataIndex: 'labels',
slotName: 'labels',
width: 200,
ellipsis: true,
},
{
title: '操作',
dataIndex: 'actions',
slotName: 'actions',
fixed: 'right',
width: 280,
},
]

View File

@@ -0,0 +1,31 @@
import type { FormItem } from '@/components/search-form/types'
export const searchFormConfig: FormItem[] = [
{
field: 'keyword',
label: '关键字',
type: 'input',
placeholder: '请输入告警名称、摘要或描述',
},
{
field: 'status',
label: '告警状态',
type: 'select',
placeholder: '请选择告警状态',
options: [
{ label: '待处理', value: 'pending' },
{ label: '告警中', value: 'firing' },
{ label: '已解决', value: 'resolved' },
{ label: '已屏蔽', value: 'silenced' },
{ label: '已抑制', value: 'suppressed' },
{ label: '已确认', value: 'acked' },
],
},
{
field: 'severity_id',
label: '告警级别',
type: 'select',
placeholder: '请选择告警级别',
options: [],
},
]

View File

@@ -0,0 +1,488 @@
<template>
<div class="alert-tackle-container">
<a-card :bordered="false" class="general-card">
<search-form
v-model="searchParams"
:form-items="searchFormConfig"
:show-buttons="false"
>
<template #extra>
<a-range-picker
v-model="timeRange"
:time-picker-props="{ defaultValue: '00:00:00' }"
format="YYYY-MM-DD HH:mm:ss"
show-time
style="width: 380px; margin-right: 12px"
/>
<a-button type="primary" @click="handleSearch">
<template #icon>
<icon-search />
</template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon>
<icon-refresh />
</template>
重置
</a-button>
</template>
</search-form>
<a-divider style="margin: 0" />
<a-row class="toolbar">
<a-col :span="12">
<a-space>
<a-button
v-if="selectedRowKeys.length > 0"
type="primary"
status="danger"
@click="handleBatchAck"
>
批量确认 ({{ selectedRowKeys.length }})
</a-button>
</a-space>
</a-col>
</a-row>
<a-table
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
:scroll="{ x: 2000 }"
@page-change="onPageChange"
@page-size-change="onPageSizeChange"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
>
<template #index="{ rowIndex }">
{{ rowIndex + 1 }}
</template>
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template #severity="{ record }">
<a-tag v-if="record.severity" :color="record.severity.color">
{{ record.severity.name || record.severity.code }}
</a-tag>
</template>
<template #starts_at="{ record }">
{{ formatDateTime(record.starts_at) }}
</template>
<template #duration="{ record }">
{{ formatDuration(record.duration) }}
</template>
<template #process_status="{ record }">
<a-tag v-if="record.process_status" color="arcoblue">
{{ record.process_status }}
</a-tag>
<span v-else>-</span>
</template>
<template #processed_at="{ record }">
{{ record.processed_at ? formatDateTime(record.processed_at) : '-' }}
</template>
<template #notify_status="{ record }">
<a-tag v-if="record.notify_status" :color="getNotifyStatusColor(record.notify_status)">
{{ getNotifyStatusText(record.notify_status) }}
</a-tag>
<span v-else>-</span>
</template>
<template #labels="{ record }">
<a-space v-if="parsedLabels(record.labels)" wrap>
<a-tag
v-for="(value, key) in parsedLabels(record.labels)"
:key="key"
size="small"
>
{{ key }}: {{ value }}
</a-tag>
</a-space>
<span v-else>-</span>
</template>
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleAck(record)">
<template #icon>
<icon-check />
</template>
确认
</a-button>
<a-button type="text" size="small" @click="handleResolve(record)">
<template #icon>
<icon-check-circle />
</template>
解决
</a-button>
<a-button type="text" size="small" @click="handleSilence(record)">
<template #icon>
<icon-eye-invisible />
</template>
屏蔽
</a-button>
<a-button type="text" size="small" @click="handleComment(record)">
<template #icon>
<icon-message />
</template>
评论
</a-button>
<a-dropdown @select="handleMoreAction($event, record)">
<a-button type="text" size="small">
更多
<icon-down />
</a-button>
<template #content>
<a-doption value="detail">详情</a-doption>
<a-doption value="process">处理记录</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
</a-table>
</a-card>
<!-- 确认对话框 -->
<ack-dialog
v-model:visible="ackDialogVisible"
:alert-record-id="currentRecord.id"
@success="handleSuccess"
/>
<!-- 解决对话框 -->
<resolve-dialog
v-model:visible="resolveDialogVisible"
:alert-record-id="currentRecord.id"
@success="handleSuccess"
/>
<!-- 屏蔽对话框 -->
<silence-dialog
v-model:visible="silenceDialogVisible"
:alert-record-id="currentRecord.id"
@success="handleSuccess"
/>
<!-- 评论对话框 -->
<comment-dialog
v-model:visible="commentDialogVisible"
:alert-record-id="currentRecord.id"
@success="handleSuccess"
/>
<!-- 详情对话框 -->
<detail-dialog
v-model:visible="detailDialogVisible"
:alert-record-id="currentRecord.id"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
IconSearch,
IconRefresh,
IconCheck,
IconCheckCircle,
IconEyeInvisible,
IconMessage,
IconDown,
} from '@arco-design/web-vue/es/icon'
import { useRouter } from 'vue-router'
import SearchForm from '@/components/search-form/index.vue'
import { searchFormConfig } from './config/search-form'
import { columns } from './config/columns'
import { fetchAlertRecords, createAlertProcess } from '@/api/ops/alertRecord'
import { fetchAlertLevelList } from '@/api/ops/alertLevel'
import AckDialog from './components/AckDialog.vue'
import ResolveDialog from './components/ResolveDialog.vue'
import SilenceDialog from './components/SilenceDialog.vue'
import CommentDialog from './components/CommentDialog.vue'
import DetailDialog from './components/DetailDialog.vue'
const router = useRouter()
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
const timeRange = ref<any[]>([])
const selectedRowKeys = ref<number[]>([])
const currentRecord = ref<any>({})
// 对话框状态
const ackDialogVisible = ref(false)
const resolveDialogVisible = ref(false)
const silenceDialogVisible = ref(false)
const commentDialogVisible = ref(false)
const detailDialogVisible = ref(false)
// 分页
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
showPageSize: true,
pageSizeOptions: ['10', '20', '50', '100'],
})
// 行选择
const rowSelection = computed(() => ({
type: 'checkbox',
showCheckedAll: true,
}))
// 搜索参数
const searchParams = ref<any>({})
// 加载告警级别列表
onMounted(async () => {
await loadSeverityOptions()
handleSearch()
})
const loadSeverityOptions = async () => {
try {
const result = await fetchAlertLevelList({ page: 1, page_size: 100 })
const severityConfig = searchFormConfig.find((item) => item.field === 'severity_id')
if (severityConfig && result.details) {
severityConfig.options = result.data.map((item: any) => ({
label: item.name || item.code,
value: item.id,
}))
}
} catch (error) {
console.error('加载告警级别失败:', error)
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
loadData()
}
const handleReset = () => {
timeRange.value = []
searchParams.value = {}
pagination.current = 1
loadData()
}
// 加载数据
const loadData = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
page_size: pagination.pageSize,
...searchParams.value,
}
// 处理时间范围
if (timeRange.value && timeRange.value.length === 2) {
params.start_time = new Date(timeRange.value[0]).toISOString()
params.end_time = new Date(timeRange.value[1]).toISOString()
}
const result = await fetchAlertRecords(params)
tableData.value = result.details.data || []
pagination.total = result.details.total || 0
} catch (error) {
console.error('加载数据失败:', error)
Message.error('加载数据失败')
} finally {
loading.value = false
}
}
// 分页
const onPageChange = (page: number) => {
pagination.current = page
loadData()
}
const onPageSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
pagination.current = 1
loadData()
}
// 行选择
const handleSelectionChange = (rowKeys: number[]) => {
selectedRowKeys.value = rowKeys
}
// 行点击
const handleRowClick = (record: any) => {
currentRecord.value = record
detailDialogVisible.value = true
}
// 处理操作
const handleAck = (record: any) => {
currentRecord.value = record
ackDialogVisible.value = true
}
const handleResolve = (record: any) => {
currentRecord.value = record
resolveDialogVisible.value = true
}
const handleSilence = (record: any) => {
currentRecord.value = record
silenceDialogVisible.value = true
}
const handleComment = (record: any) => {
currentRecord.value = record
commentDialogVisible.value = true
}
// 批量确认
const handleBatchAck = async () => {
try {
const promises = selectedRowKeys.value.map((id) =>
createAlertProcess({
alert_record_id: id,
action: 'ack',
operator: getCurrentUser(),
comment: '批量确认',
})
)
await Promise.all(promises)
Message.success(`成功确认 ${selectedRowKeys.value.length} 条告警`)
selectedRowKeys.value = []
loadData()
} catch (error) {
console.error('批量确认失败:', error)
Message.error('批量确认失败')
}
}
// 更多操作
const handleMoreAction = (action: string, record: any) => {
currentRecord.value = record
switch (action) {
case 'detail':
detailDialogVisible.value = true
break
case 'process':
router.push({
path: '/ops/alert/process',
query: { alert_record_id: record.id },
})
break
}
}
// 操作成功回调
const handleSuccess = () => {
loadData()
}
// 格式化函数
const formatDateTime = (datetime: string) => {
if (!datetime) return '-'
return new Date(datetime).toLocaleString('zh-CN')
}
const formatDuration = (seconds: number) => {
if (!seconds) return '-'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${hours}小时${minutes}分钟`
}
return `${minutes}分钟`
}
const parsedLabels = (labels: string) => {
if (!labels) return null
try {
return JSON.parse(labels)
} catch {
return null
}
}
// 状态相关
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
firing: 'red',
pending: 'orange',
acked: 'gold',
resolved: 'green',
silenced: 'gray',
suppressed: 'lightgray',
}
return colorMap[status] || 'blue'
}
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
firing: '告警中',
pending: '待处理',
acked: '已确认',
resolved: '已解决',
silenced: '已屏蔽',
suppressed: '已抑制',
}
return textMap[status] || status
}
const getNotifyStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
success: 'green',
failed: 'red',
pending: 'orange',
}
return colorMap[status] || 'blue'
}
const getNotifyStatusText = (status: string) => {
const textMap: Record<string, string> = {
success: '成功',
failed: '失败',
pending: '待发送',
}
return textMap[status] || status
}
function getCurrentUser() {
// TODO: 从全局状态获取当前用户
return 'admin'
}
</script>
<style scoped lang="less">
.alert-tackle-container {
padding: 20px;
.general-card {
padding: 20px;
}
.toolbar {
padding: 16px 0;
}
}
</style>