501 lines
17 KiB
Vue
501 lines
17 KiB
Vue
<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>
|