Files
front/src/views/ops/pages/alert/template/components/RuleEditor.vue

501 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="rule-editor">
<a-form ref="ruleFormRef" :model="{ rules: localRules }" layout="vertical">
<div v-if="localRules.length === 0" class="empty-rules">
<a-empty description="暂无规则,请点击上方按钮添加" />
</div>
<div v-else class="rules-container">
<a-card v-for="(rule, index) in localRules" :key="index" class="rule-card" :title="`规则 ${index + 1}: ${rule.name || '未命名'}`">
<template #extra>
<a-button type="text" size="small" status="danger" @click="handleRemoveRule(index)">
<template #icon><icon-delete /></template>
删除
</a-button>
</template>
<!-- 第一行基本信息 -->
<div class="rule-section">
<div class="section-title">基本信息</div>
<a-row :gutter="16">
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="规则名称" :field="`rules[${index}].name`" :rules="getRuleFieldRules('name')">
<a-input v-model="rule.name" placeholder="请输入规则名称" @change="syncUpdate(index)" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="数据源" :field="`rules[${index}].data_source`" :rules="getRuleFieldRules('data_source')">
<a-select v-model="rule.data_source" placeholder="请选择" @change="handleRuleDataSourceChange(index)">
<a-option v-for="item in DATA_SOURCES" :key="item.value" :value="item.value">
{{ item.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="指标名称" :field="`rules[${index}].metric_name`" :rules="getRuleFieldRules('metric_name')">
<a-select
v-model="rule.metric_name"
placeholder="请选择指标"
allow-search
:loading="rule._metricsLoading"
@dropdown-visible-change="handleMetricsDropdownChange($event, index)"
@change="syncUpdate(index)"
>
<a-option v-for="metric in rule._metrics" :key="metric.metric_name" :value="metric.metric_name">
{{ metric.metric_name }} ({{ metric.metric_unit || '无单位' }})
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="告警级别" :field="`rules[${index}].severity_code`" :rules="getRuleFieldRules('severity_code')">
<a-select
v-model="rule.severity_code"
placeholder="请选择"
:loading="severityLoading"
allow-search
:filter-option="false"
@search="handleSeveritySearch"
@dropdown-visible-change="handleSeverityDropdownChange"
@change="syncUpdate(index)"
>
<a-option v-for="item in localSeverityOptions" :key="item.code" :value="item.code">
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</div>
<!-- 第二行条件配置 -->
<div class="rule-section">
<div class="section-title">触发条件</div>
<a-row :gutter="16">
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="规则类型" :field="`rules[${index}].rule_type`" :rules="getRuleFieldRules('rule_type')">
<a-select v-model="rule.rule_type" placeholder="请选择" @change="handleRuleTypeChange(index)">
<a-option v-for="item in RULE_TYPES" :key="item.value" :value="item.value">
{{ item.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="比较运算符" :field="`rules[${index}].compare_op`" :rules="getRuleFieldRules('compare_op')">
<a-select v-model="rule.compare_op" placeholder="请选择" @change="syncUpdate(index)">
<a-option v-for="item in availableCompareOperators(rule.rule_type)" :key="item.value" :value="item.value">
{{ item.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item
:label="rule.rule_type === 'dynamic' ? '偏离比例(%' : '阈值'"
:field="`rules[${index}].threshold`"
:rules="getRuleFieldRules('threshold')"
>
<a-input-number
v-model="rule.threshold"
:placeholder="rule.rule_type === 'dynamic' ? '请输入偏离比例' : '请输入阈值'"
:min="rule.rule_type === 'dynamic' ? 0 : undefined"
style="width: 100%"
@change="syncUpdate(index)"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="持续时间" :field="`rules[${index}].duration`" :rules="getRuleFieldRules('duration')">
<a-input-number v-model="rule.duration" placeholder="请输入" :min="0" style="width: 100%" @change="syncUpdate(index)">
<template #suffix></template>
</a-input-number>
</a-form-item>
</a-col>
</a-row>
</div>
<!-- 第三行评估配置 -->
<div class="rule-section">
<div class="section-title">评估配置</div>
<a-row :gutter="16">
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="评估间隔" :field="`rules[${index}].eval_interval`" :rules="getRuleFieldRules('eval_interval')">
<a-input-number
v-model="rule.eval_interval"
placeholder="请输入"
:min="1"
style="width: 100%"
@change="syncUpdate(index)"
>
<template #suffix></template>
</a-input-number>
</a-form-item>
</a-col>
<template v-if="rule.rule_type === 'dynamic' && rule._baseline">
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="基线回看时长" :field="`rules[${index}]._baseline.lookback_seconds`" required>
<a-input-number
v-model="rule._baseline.lookback_seconds"
:min="Math.max(rule.eval_interval || 1, 1)"
style="width: 100%"
@change="syncUpdate(index)"
>
<template #suffix></template>
</a-input-number>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="历史基线聚合" :field="`rules[${index}]._baseline.aggregation`" required>
<a-select v-model="rule._baseline.aggregation" @change="syncUpdate(index)">
<a-option value="avg">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="当前窗口聚合" :field="`rules[${index}]._baseline.current_aggregation`" required>
<a-select v-model="rule._baseline.current_aggregation" @change="syncUpdate(index)">
<a-option value="avg">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="基线最小样本数" :field="`rules[${index}]._baseline.min_samples`" required>
<a-input-number v-model="rule._baseline.min_samples" :min="1" style="width: 100%" @change="syncUpdate(index)" />
</a-form-item>
</a-col>
</template>
</a-row>
</div>
</a-card>
</div>
</a-form>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import type { FormInstance } from '@arco-design/web-vue'
import {
fetchSeverityOptions,
fetchMetricsMeta,
DATA_SOURCES,
COMPARE_OPERATORS,
RULE_TYPES,
type AlertRule,
type AlertSeverity,
type MetricMeta,
} from '@/api/ops/alertTemplate'
interface RuleItem extends AlertRule {
_metrics?: MetricMeta[]
_metricsLoading?: boolean
_baseline?: BaselineForm
}
interface BaselineForm {
lookback_seconds: number
aggregation: 'avg' | 'max' | 'min'
current_aggregation: 'avg' | 'max' | 'min'
min_samples: number
}
const props = defineProps<{
rules: RuleItem[]
severityOptions: AlertSeverity[]
}>()
const emit = defineEmits<{
(e: 'update:rules', value: RuleItem[]): void
(e: 'remove', index: number): void
}>()
// 表单引用
const ruleFormRef = ref<FormInstance | null>(null)
// 本地规则数据(深拷贝)
const localRules = ref<RuleItem[]>([])
// 告警级别下拉选项(支持搜索后局部刷新)
const baseSeverityOptions = ref<AlertSeverity[]>([])
const localSeverityOptions = ref<AlertSeverity[]>([])
const severityLoading = ref(false)
const defaultBaselineConfig = (): BaselineForm => ({
lookback_seconds: 3600,
aggregation: 'avg',
current_aggregation: 'avg',
min_samples: 1,
})
const parseBaselineConfig = (raw?: string): BaselineForm => {
try {
const parsed = JSON.parse(raw || '{}')
return {
lookback_seconds: Number(parsed.lookback_seconds) > 0 ? Number(parsed.lookback_seconds) : 3600,
aggregation: ['avg', 'max', 'min'].includes(parsed.aggregation) ? parsed.aggregation : 'avg',
current_aggregation: ['avg', 'max', 'min'].includes(parsed.current_aggregation) ? parsed.current_aggregation : 'avg',
min_samples: Number(parsed.min_samples) > 0 ? Number(parsed.min_samples) : 1,
}
} catch {
return defaultBaselineConfig()
}
}
// 监听 props 变化,深拷贝到本地
watch(
() => props.rules,
(val) => {
const copied: RuleItem[] = JSON.parse(JSON.stringify(val))
localRules.value = copied.map((rule) => ({
...rule,
compare_op: rule.rule_type === 'dynamic' && ['==', '!='].includes(rule.compare_op) ? '>' : rule.compare_op,
_baseline: parseBaselineConfig(rule.baseline_config),
}))
},
{ immediate: true, deep: true }
)
// 初始化/同步告警级别选项
watch(
() => props.severityOptions,
(val) => {
baseSeverityOptions.value = val || []
localSeverityOptions.value = val || []
},
{ immediate: true }
)
let severitySearchTimer: ReturnType<typeof setTimeout> | null = null
const loadSeverityOptions = async (keyword?: string) => {
severityLoading.value = true
try {
const res: any = await fetchSeverityOptions({
keyword: keyword || undefined,
enabled: 'true',
})
const list = Array.isArray(res?.details) ? res.details : res?.details?.data || res?.details?.list || res?.data || res
const fetchedOptions = Array.isArray(list) ? list : []
// 合并当前已选值,防止搜索后 options 缩小导致已选项标签无法展示
const selectedCodes = new Set(
localRules.value.map((r) => r?.severity_code).filter((v): v is string => typeof v === 'string' && v.length > 0)
)
const selectedOptions = baseSeverityOptions.value.filter((s) => selectedCodes.has(s.code))
const mergedMap = new Map<string, AlertSeverity>()
;[...fetchedOptions, ...selectedOptions].forEach((item) => {
if (item?.code) {
mergedMap.set(item.code, item)
}
})
localSeverityOptions.value = Array.from(mergedMap.values())
} catch (error) {
console.error('加载告警级别下拉选项失败:', error)
} finally {
severityLoading.value = false
}
}
const handleSeverityDropdownChange = (visible: boolean) => {
if (visible && localSeverityOptions.value.length === 0) {
loadSeverityOptions()
}
}
const handleSeveritySearch = (keyword: string) => {
if (severitySearchTimer) {
clearTimeout(severitySearchTimer)
}
severitySearchTimer = setTimeout(() => {
loadSeverityOptions(keyword)
}, 300)
}
// 动态生成验证规则
const getRuleFieldRules = (field: string) => {
const messages: Record<string, string> = {
name: '请输入规则名称',
data_source: '请选择数据源',
metric_name: '请选择指标名称',
rule_type: '请选择规则类型',
compare_op: '请选择比较运算符',
threshold: '请输入阈值',
duration: '请输入持续时间',
eval_interval: '请输入评估间隔',
severity_code: '请选择告警级别',
}
return [{ required: true, message: messages[field] || '该字段为必填项' }]
}
const availableCompareOperators = (ruleType: string) =>
ruleType === 'dynamic' ? COMPARE_OPERATORS.filter((item) => item.value !== '==' && item.value !== '!=') : COMPARE_OPERATORS
// 暴露验证方法给父组件
const validate = async () => {
return ruleFormRef.value?.validate()
}
defineExpose({
validate,
})
// 同步更新到父组件
const syncUpdate = (index: number) => {
const newRules = [...props.rules]
const { _metrics, _metricsLoading, _baseline, ...rest } = localRules.value[index]
if (rest.rule_type === 'dynamic' && ['==', '!='].includes(rest.compare_op)) {
rest.compare_op = '>'
}
const baseline_config = rest.rule_type === 'dynamic' ? JSON.stringify(_baseline || defaultBaselineConfig()) : '{}'
newRules[index] = { ...rest, baseline_config, _metrics, _metricsLoading } as RuleItem
emit('update:rules', newRules)
}
// 移除规则
const handleRemoveRule = (index: number) => {
emit('remove', index)
}
// 规则数据源变化
const handleRuleDataSourceChange = (index: number) => {
localRules.value[index].metric_name = ''
localRules.value[index]._metrics = []
syncUpdate(index)
// 数据源变化后自动加载指标
handleLoadMetrics(index)
}
const handleRuleTypeChange = (index: number) => {
const rule = localRules.value[index]
if (rule.rule_type === 'dynamic') {
if (rule.compare_op === '==' || rule.compare_op === '!=') {
rule.compare_op = '>'
}
rule._baseline ||= parseBaselineConfig(rule.baseline_config)
}
syncUpdate(index)
}
// 下拉框展开时加载指标(备用触发)
const handleMetricsDropdownChange = (visible: boolean, index: number) => {
if (visible) {
handleLoadMetrics(index)
}
}
// 加载指标
const handleLoadMetrics = async (index: number) => {
// 每次都重新获取引用,避免引用失效
if (!localRules.value[index]?.data_source) {
return
}
if (localRules.value[index]._metrics && localRules.value[index]._metrics!.length > 0) {
return
}
localRules.value[index]._metricsLoading = true
try {
const res: any = await fetchMetricsMeta({
data_source: localRules.value[index].data_source,
limit: 500,
})
if (res.code === 0) {
localRules.value[index]._metrics = res.details?.metrics || []
// 同步更新到父组件
syncUpdate(index)
}
} catch (error) {
console.error('加载指标失败:', error)
} finally {
localRules.value[index]._metricsLoading = false
}
}
</script>
<script lang="ts">
export default {
name: 'RuleEditor',
}
</script>
<style scoped lang="less">
.rule-editor {
height: 100%;
}
.empty-rules {
padding: 40px 20px;
text-align: center;
}
.rules-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.rule-card {
:deep(.arco-card-header) {
border-bottom: 1px solid var(--color-border);
background-color: var(--color-fill-1);
}
:deep(.arco-card-body) {
padding: 16px;
}
}
.rule-section {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 13px;
font-weight: 500;
color: var(--color-text-2);
margin-bottom: 12px;
padding-left: 8px;
border-left: 3px solid rgb(var(--primary-6));
}
}
// 表单项样式
:deep(.arco-form-item) {
margin-bottom: 0;
}
// 响应式布局
@media (max-width: 768px) {
.rule-card {
:deep(.arco-card-body) {
padding: 12px;
}
}
.rule-section {
.section-title {
margin-bottom: 8px;
}
}
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
</style>