This commit is contained in:
zxr
2026-03-25 22:05:50 +08:00
parent 3e7758efdd
commit 7e46f8b4e1
18 changed files with 497 additions and 135 deletions

View File

@@ -72,7 +72,9 @@ export const createRule = (data: {
threshold?: number;
compare_op?: string;
duration?: number;
baseline_config?: string;
labels?: string;
annotations?: string;
}) => request.post("/Alert/v1/rule/create", data);
/** 更新 告警规则 */
@@ -88,7 +90,9 @@ export const updateRule = (data: {
threshold?: number;
compare_op?: string;
duration?: number;
baseline_config?: string;
labels?: string;
annotations?: string;
}) => request.post("/Alert/v1/rule/update", data);
/** 删除 告警规则 */

View File

@@ -33,7 +33,8 @@ export const createAlertProcess = (data: {
escalate_to?: string;
root_cause?: string;
solution?: string;
metadata?: Record<string, any>;
// 按后端约定metadata 为 JSON 字符串,例如 "{}"
metadata?: string;
}) => request.post("/Alert/v1/process/create", data);
/** 获取 告警处理记录列表 */

View File

@@ -52,12 +52,6 @@
</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>
@@ -188,28 +182,6 @@ const getStatusText = (status: string) => {
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

View File

@@ -0,0 +1,372 @@
<template>
<a-modal
:visible="visible"
width="1200px"
title="告警处理记录"
:footer="false"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
>
<a-spin :loading="loading" style="width: 100%">
<div v-if="preloadedAlertRecord" style="margin-bottom: 16px;">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="告警ID">{{ preloadedAlertRecord.id }}</a-descriptions-item>
<a-descriptions-item label="告警名称">
{{ preloadedAlertRecord.alert_name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(preloadedAlertRecord.status)">
{{ getStatusText(preloadedAlertRecord.status) }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
</div>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="处理记录列表"
:show-toolbar="false"
:show-download="false"
:show-refresh="false"
:show-density="false"
:show-column-setting="false"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
>
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<template #created_at="{ record }">
{{ formatDateTime(record.created_at) || '-' }}
</template>
<template #resolution_time="{ record }">
{{ formatDateTime(record.resolution_time) || '-' }}
</template>
<template #action="{ record }">
<a-tag :color="getActionColor(record.action)">
{{ getActionText(record.action) }}
</a-tag>
</template>
</search-table>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } 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 { fetchAlertProcessList, fetchHistoryDetail } from '@/api/ops/alertHistory'
interface Props {
visible: boolean
alertRecordId?: number
alertRecord?: any
}
interface Emits {
(e: 'update:visible', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const loading = ref(false)
const tableData = ref<any[]>([])
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formModel = ref({
action: undefined as string | undefined,
keyword: '',
})
const preloadedAlertRecord = ref<any>(props.alertRecord || null)
watch(
() => props.alertRecord,
(val) => {
preloadedAlertRecord.value = val || null
},
{ immediate: true },
)
const formItems = computed<FormItem[]>(() => [
{
field: 'action',
label: '动作',
type: 'select',
placeholder: '请选择动作',
options: [
{ label: '确认', value: 'ack' },
{ label: '解决', value: 'resolve' },
{ label: '屏蔽', value: 'silence' },
{ label: '评论', value: 'comment' },
{ label: '分配', value: 'assign' },
{ label: '升级', value: 'escalate' },
{ label: '关闭', value: 'close' },
],
},
{
field: 'keyword',
label: '关键字',
type: 'input',
placeholder: '匹配操作人/备注',
},
])
const columns = computed<TableColumnData[]>(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '时间',
dataIndex: 'created_at',
slotName: 'created_at',
width: 180,
},
{
title: '动作',
dataIndex: 'action',
slotName: 'action',
width: 110,
},
{
title: '操作人',
dataIndex: 'operator',
width: 140,
ellipsis: true,
tooltip: true,
},
{
title: '备注',
dataIndex: 'comment',
width: 320,
ellipsis: true,
tooltip: true,
},
{
title: '完成时间',
dataIndex: 'resolution_time',
slotName: 'resolution_time',
width: 200,
},
])
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 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
}
const formatDateTime = (dateStr: string) => {
if (!dateStr) return ''
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return dateStr
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
const maybePreloadAlertRecord = async () => {
if (
preloadedAlertRecord.value &&
props.alertRecordId &&
preloadedAlertRecord.value.id === props.alertRecordId
) {
return
}
if (!props.alertRecordId) return
loading.value = true
try {
const res: any = await fetchHistoryDetail(props.alertRecordId)
if (res?.code === 0 && res?.details) {
preloadedAlertRecord.value = res.details
}
} catch (error) {
console.error('加载告警记录失败:', error)
Message.error('加载告警记录失败')
} finally {
loading.value = false
}
}
const loadProcessList = async () => {
if (!props.alertRecordId) return
loading.value = true
try {
const params: any = {
alert_record_id: props.alertRecordId,
action: formModel.value.action || undefined,
keyword: formModel.value.keyword || undefined,
page: pagination.current,
page_size: pagination.pageSize,
}
const res: any = await fetchAlertProcessList(params)
tableData.value = res?.details?.data || []
pagination.total = res?.details?.total || 0
// 预加载 AlertRecord补齐到每一条处理记录上接口返回为 null 时也能展示/联动)
if (preloadedAlertRecord.value) {
const alertRecord = preloadedAlertRecord.value
tableData.value.forEach((item) => {
if (!item.alert_record) {
item.alert_record = alertRecord
}
})
}
} catch (error) {
console.error('获取处理记录失败:', error)
Message.error('获取处理记录失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
const init = async () => {
pagination.current = 1
pagination.pageSize = pagination.pageSize || 20
tableData.value = []
pagination.total = 0
formModel.value = {
action: undefined,
keyword: '',
}
// 强制按当前告警ID刷新预加载对象避免切换时短暂复用旧数据
preloadedAlertRecord.value = props.alertRecord || null
await maybePreloadAlertRecord()
await loadProcessList()
}
watch(
() => props.visible,
(val) => {
if (val) {
init()
}
},
)
const handleCancel = () => {
emit('update:visible', false)
}
const handleVisibleChange = (val: boolean) => {
emit('update:visible', val)
}
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
const handleSearch = () => {
pagination.current = 1
loadProcessList()
}
const handleReset = () => {
formModel.value = {
action: undefined,
keyword: '',
}
pagination.current = 1
loadProcessList()
}
const handlePageChange = (current: number) => {
pagination.current = current
loadProcessList()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
pagination.current = 1
loadProcessList()
}
</script>
<script lang="ts">
export default {
name: 'HistoryProcessListDialog',
}
</script>

View File

@@ -51,12 +51,6 @@ export const columns: TableColumnData[] = [
slotName: 'duration',
width: 120,
},
{
title: '处理状态',
dataIndex: 'process_status',
slotName: 'process_status',
width: 100,
},
{
title: '处理人',
dataIndex: 'processed_by',

View File

@@ -85,14 +85,6 @@
{{ 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) || '-' }}
@@ -114,6 +106,13 @@
v-model:visible="detailVisible"
:record-id="currentRecordId"
/>
<!-- 告警处理记录对话框 -->
<history-process-list-dialog
v-model:visible="processVisible"
:alert-record-id="currentProcessAlertRecordId"
:alert-record="currentProcessAlertRecord"
/>
</div>
</template>
@@ -122,13 +121,13 @@ 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'
import HistoryProcessListDialog from './components/HistoryProcessListDialog.vue'
// 状态管理
const loading = ref(false)
@@ -222,6 +221,13 @@ const currentRecordId = ref<number | undefined>(undefined)
// 对话框可见性
const detailVisible = ref(false)
// 处理记录对话框可见性
const processVisible = ref(false)
// 当前用于“处理记录”的告警记录
const currentProcessAlertRecord = ref<any>(null)
const currentProcessAlertRecordId = computed(() => currentProcessAlertRecord.value?.id)
// 加载告警级别列表
const loadSeverityOptions = async () => {
try {
@@ -321,28 +327,6 @@ const getStatusText = (status: string) => {
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
@@ -390,7 +374,8 @@ const handleDetail = (record: any) => {
// 查看处理记录
const handleViewProcess = (record: any) => {
Message.info(`查看告警 ${record.id} 的处理记录功能待实现`)
currentProcessAlertRecord.value = record
processVisible.value = true
}
// 初始化加载数据

View File

@@ -355,6 +355,9 @@ const handleOk = async () => {
submitting.value = true
try {
const quietHoursPayload =
form.value.quiet_hours && form.value.quiet_hours.trim() ? form.value.quiet_hours : '{}'
const data: any = {
name: form.value.name,
type: form.value.type,
@@ -365,7 +368,7 @@ const handleOk = async () => {
severity_filter: Array.isArray(form.value.severity_filter)
? form.value.severity_filter.join(',')
: form.value.severity_filter,
quiet_hours: form.value.quiet_hours,
quiet_hours: quietHoursPayload,
retry_times: form.value.retry_times,
retry_interval: form.value.retry_interval,
enabled: form.value.enabled,

View File

@@ -100,7 +100,7 @@ const pagination = reactive({
})
const formModel = ref<Record<string, any>>({
name: '',
keyword: '',
type: '',
})
@@ -111,7 +111,7 @@ const handleFormModelUpdate = (value: Record<string, any>) => {
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'name',
field: 'keyword',
label: '渠道名称',
type: 'input',
placeholder: '请输入渠道名称',
@@ -234,7 +234,7 @@ const handleSearch = () => {
// 重置
const handleReset = () => {
formModel.value = {
name: '',
keyword: '',
type: '',
}
pagination.current = 1

View File

@@ -373,8 +373,11 @@ const handleCancel = () => {
}
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
try {
await formRef.value?.validate()
} catch {
return
}
submitLoading.value = true
try {
@@ -391,7 +394,11 @@ const handleSubmit = async () => {
dispatch_rule: formData.dispatch_rule || undefined,
}
await createPolicy(data)
const res = await createPolicy(data)
// 后端是业务 code 约定;接口返回失败时不应继续展示成功提示
if (res?.code !== 0) {
throw new Error(res?.message || '创建策略失败')
}
Message.success('策略创建成功')
emit('success')
handleCancel()

View File

@@ -510,10 +510,13 @@ const handleCancel = () => {
resetForm()
}
// 提交
// 提交Arco Form.validate() 校验通过时 resolve 为 undefined不能写成 if (!valid)
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
try {
await formRef.value?.validate()
} catch {
return
}
submitLoading.value = true

View File

@@ -29,11 +29,8 @@
<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>
@@ -89,33 +86,6 @@
/>
</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">
@@ -316,7 +286,8 @@ const loadRuleDetail = async () => {
const rule = res.details
formData.name = rule.name
formData.rule_type = rule.rule_type || 'static'
// 当前产品只保留静态规则:强制按 static 渲染
formData.rule_type = 'static'
formData.enabled = rule.enabled
formData.severity_id = rule.severity_id
formData.metric_name = rule.metric_name || ''
@@ -357,10 +328,13 @@ const handleCancel = () => {
resetForm()
}
// 提交
// 提交Arco Form.validate() 通过时 resolve 为 undefined不可写成 if (!valid)
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
try {
await formRef.value?.validate()
} catch {
return
}
if (!props.policyId) {
Message.warning('缺少策略 ID')
@@ -376,13 +350,24 @@ const handleSubmit = async () => {
severity_id: formData.severity_id, // 确保 severity_id 是 number 类型
}
// 按契约rule/create 不做 jsonb 默认值归一化。
// 当 jsonb 字段无内容时,必须显式传 "{}"(否则可能反序列化为空串导致 SQLSTATE 22P02
const createPayload = !isEdit.value
? {
...data,
baseline_config: '{}',
labels: '{}',
annotations: '{}',
}
: data
// 编辑模式
if (isEdit.value && props.ruleId) {
await updateRule({ id: props.ruleId, ...data })
Message.success('规则更新成功')
} else {
// 新建模式
await createRule(data)
await createRule(createPayload)
Message.success('规则创建成功')
}

View File

@@ -62,6 +62,7 @@ const handleOk = async () => {
action: 'ack',
operator: getCurrentUser(),
comment: form.value.comment,
metadata: '{}',
})
Message.success('确认成功')
emit('success')

View File

@@ -67,6 +67,7 @@ const handleOk = async () => {
action: 'comment',
operator: getCurrentUser(),
comment: form.value.comment,
metadata: '{}',
})
Message.success('评论添加成功')
emit('success')

View File

@@ -61,9 +61,6 @@
<!-- 处理信息 -->
<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>

View File

@@ -82,6 +82,7 @@ const handleOk = async () => {
comment: form.value.comment,
root_cause: form.value.root_cause,
solution: form.value.solution,
metadata: '{}',
})
Message.success('解决成功')
emit('success')

View File

@@ -93,6 +93,7 @@ const handleOk = async () => {
silence_until: silenceUntil,
silence_reason: form.value.silence_reason,
comment: form.value.comment,
metadata: '{}',
})
Message.success('屏蔽成功')
emit('success')

View File

@@ -57,12 +57,6 @@ export const columns: TableColumnData[] = [
slotName: 'duration',
width: 120,
},
{
title: '处理状态',
dataIndex: 'process_status',
slotName: 'process_status',
width: 100,
},
{
title: '处理人',
dataIndex: 'processed_by',

View File

@@ -63,13 +63,6 @@
{{ 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>
@@ -90,16 +83,36 @@
<template #actions="{ record }">
<a-space size="small" :wrap="false">
<a-button type="text" size="small" @click.stop="handleAck(record)">
<a-button
type="text"
size="small"
:disabled="isActionDisabled(record, 'ack')"
@click.stop="handleAck(record)"
>
确认
</a-button>
<a-button type="text" size="small" @click.stop="handleResolve(record)">
<a-button
type="text"
size="small"
:disabled="isActionDisabled(record, 'resolve')"
@click.stop="handleResolve(record)"
>
解决
</a-button>
<a-button type="text" size="small" @click.stop="handleSilence(record)">
<a-button
type="text"
size="small"
:disabled="isActionDisabled(record, 'silence')"
@click.stop="handleSilence(record)"
>
屏蔽
</a-button>
<a-button type="text" size="small" @click.stop="handleComment(record)">
<a-button
type="text"
size="small"
:disabled="isActionDisabled(record, 'comment')"
@click.stop="handleComment(record)"
>
评论
</a-button>
<a-dropdown @select="(v) => handleMoreSelect(v, record)">
@@ -286,6 +299,34 @@ const handleRowClick = (record: any, _ev: Event) => {
detailDialogVisible.value = true
}
type ProcessAction = 'ack' | 'resolve' | 'silence' | 'comment'
// 根据告警受理处理的状态record.status控制操作列按钮可点击性
// 约定:
// - pending/firing允许 ack/resolve/silence/comment
// - acked禁用 ack其它允许
// - resolved只允许 comment
// - silenced/suppressed只允许 comment
const isActionDisabled = (record: any, action: ProcessAction) => {
const status = record?.status as string | undefined
if (!status) return false
const isResolved = status === 'resolved'
const isAcked = status === 'acked'
const isSilenced = status === 'silenced'
const isSuppressed = status === 'suppressed'
if (isResolved || isSilenced || isSuppressed) {
return action !== 'comment'
}
if (isAcked) {
return action === 'ack'
}
return false
}
const handleAck = (record: any) => {
currentRecord.value = record
ackDialogVisible.value = true