feat: 告警规则关联真实通知渠道 — 规则表单选择已配置渠道替代硬编码类型

- 规则创建/编辑表单: 用实际已配置的通知渠道列表(运维钉钉群/DBA邮箱等)替代硬编码的email/sms/dingtalk类型开关
- 规则表notify_channels列: 渠道ID解析为渠道名称+类型图标(替代原先仅显示3个固定图标)
- 组件挂载时预加载notifyChannels(原先仅在通知渠道弹窗打开时加载)
- 向后兼容: resolveChannelRef同时支持新格式(渠道ID)和旧格式(类型字符串)
- 数据修复: 恢复3条被软删除的种子通知渠道记录
This commit is contained in:
attacker committed 2026-06-14 12:08:36 +08:00
1 parent 7fc0701acb
commit f3e1b81a3a
1 file changed
+99 -36
@@ -50,10 +50,6 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
function hasChannel(notifyChannels: any, channel: string): boolean {
return parseChannels(notifyChannels).includes(channel);
}
// 将 notify_channels (可能是数组/JSON字符串/逗号分隔) 解析为数组 // 将 notify_channels (可能是数组/JSON字符串/逗号分隔) 解析为数组
function parseChannels(notifyChannels: any): string[] { function parseChannels(notifyChannels: any): string[] {
if (!notifyChannels) return []; if (!notifyChannels) return [];
@@ -70,6 +66,32 @@ function parseChannels(notifyChannels: any): string[] {
return []; return [];
} }
// 已知的渠道类型(用于旧数据兼容 — 旧规则存储类型字符串而非渠道 ID)
const KNOWN_CHANNEL_TYPES = ['email', 'sms', 'dingtalk', 'wechat', 'webhook'];
// 根据渠道类型获取图标组件名(用字符串映射,组件内用 getChannelIcon 返回实际组件)
const CHANNEL_TYPE_LABELS: Record<string, string> = {
dingtalk: '钉钉',
wechat: '企业微信',
email: '邮件',
sms: '短信',
webhook: 'Webhook',
};
// 解析单个 notify_channels 引用值 — 返回 {id?, type, name, isLegacy}
// 新格式存渠道 ID (string),旧格式存类型字符串 (email/dingtalk 等)
function resolveChannelRef(value: string, channels: any[]): { type: string; name: string; isLegacy: boolean } | null {
if (!value) return null;
// 尝试按 ID 匹配已配置渠道
const ch = channels.find((c) => String(c.id) === String(value));
if (ch) return { type: ch.type || 'unknown', name: ch.name || ch.type || '未命名', isLegacy: false };
// 兼容旧格式:值本身是类型字符串
if (KNOWN_CHANNEL_TYPES.includes(value)) {
return { type: value, name: CHANNEL_TYPE_LABELS[value] || value, isLegacy: true };
}
return null;
}
export function AlertManager() { export function AlertManager() {
const [activeTab, setActiveTab] = useState('events'); const [activeTab, setActiveTab] = useState('events');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -288,15 +310,57 @@ export function AlertManager() {
return CHANNEL_TYPES.find((t) => t.key === type)?.label || type; return CHANNEL_TYPES.find((t) => t.key === type)?.label || type;
}; };
// 根据渠道类型返回对应图标组件
const getChannelIcon = (type: string) => {
switch (type) {
case 'email':
return Mail;
case 'sms':
return Phone;
case 'dingtalk':
case 'wechat':
return MessageSquare;
default:
return Bell;
}
};
// 渲染规则的通知渠道引用列表(兼容新格式=渠道ID 和 旧格式=类型字符串)
const renderRuleChannels = (rule: any) => {
const refs = parseChannels(rule.notify_channels);
if (refs.length === 0) return <span className="text-xs text-slate-600">--</span>;
return (
<div className="flex flex-wrap gap-1">
{refs.map((ref) => {
const resolved = resolveChannelRef(ref, notifyChannels);
if (!resolved) return null;
const Icon = getChannelIcon(resolved.type);
return (
<span
key={ref}
title={`${resolved.name} (${getChannelTypeLabel(resolved.type)})`}
className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-slate-800 text-slate-300"
>
<Icon className="h-3 w-3 text-slate-400" />
{resolved.name}
</span>
);
})}
</div>
);
};
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true); setLoading(true);
const [alertsData, rulesData] = await Promise.all([ const [alertsData, rulesData, channelsData] = await Promise.all([
alertApi.getAlerts(), alertApi.getAlerts(),
alertApi.getRules(), alertApi.getRules(),
alertApi.getNotifyChannels(),
]); ]);
setAlerts((alertsData as any)?.list || []); setAlerts((alertsData as any)?.list || []);
setRules((rulesData as any)?.list || (Array.isArray(rulesData) ? rulesData : [])); setRules((rulesData as any)?.list || (Array.isArray(rulesData) ? rulesData : []));
setNotifyChannels((channelsData as any)?.list || []);
} catch (error) { } catch (error) {
console.error('Failed to load alert data:', error); console.error('Failed to load alert data:', error);
} finally { } finally {
@@ -702,17 +766,7 @@ export function AlertManager() {
<span className="text-sm text-slate-300">{(rule.duration || 0) / 60}分钟</span> <span className="text-sm text-slate-300">{(rule.duration || 0) / 60}分钟</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex gap-1"> {renderRuleChannels(rule)}
{hasChannel(rule.notify_channels, 'email') && (
<Mail className="h-4 w-4 text-slate-400" />
)}
{hasChannel(rule.notify_channels, 'sms') && (
<Phone className="h-4 w-4 text-slate-400" />
)}
{hasChannel(rule.notify_channels, 'dingtalk') && (
<MessageSquare className="h-4 w-4 text-slate-400" />
)}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Switch <Switch
@@ -865,26 +919,35 @@ export function AlertManager() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-slate-300">通知渠道</Label> <Label className="text-slate-300">通知渠道</Label>
<div className="flex items-center gap-4 pt-1"> {notifyChannels.length === 0 ? (
{[ <div className="text-xs text-amber-400/80 bg-amber-500/5 border border-amber-500/20 rounded-md px-3 py-2">
{ key: 'email', label: '邮件', icon: Mail }, 暂无已配置的通知渠道,请先在「通知渠道配置」中添加
{ key: 'sms', label: '短信', icon: Phone }, </div>
{ key: 'dingtalk', label: '钉钉', icon: MessageSquare }, ) : (
].map(({ key, label, icon: Icon }) => ( <div className="flex flex-wrap gap-3 pt-1">
<label {notifyChannels.map((ch) => {
key={key} const Icon = getChannelIcon(ch.type);
className="flex items-center gap-2 cursor-pointer select-none text-sm text-slate-300" const checked = ruleForm.notify_channels.includes(String(ch.id));
> return (
<Switch <label
checked={ruleForm.notify_channels.includes(key)} key={ch.id}
onCheckedChange={() => toggleNotifyChannel(key)} className="flex items-center gap-2 cursor-pointer select-none text-sm text-slate-300"
className="data-[state=checked]:bg-blue-600" >
/> <Switch
<Icon className="h-4 w-4 text-slate-400" /> checked={checked}
{label} onCheckedChange={() => toggleNotifyChannel(String(ch.id))}
</label> className="data-[state=checked]:bg-blue-600"
))} />
</div> <Icon className="h-4 w-4 text-slate-400" />
<span>
{ch.name}
<span className="text-xs text-slate-500 ml-1">({getChannelTypeLabel(ch.type)})</span>
</span>
</label>
);
})}
</div>
)}
</div> </div>
</div> </div>