feat: 云账号编辑功能 + 通知渠道种子数据 + 修复mock.go编译错误

- 新增 PUT /cloud-accounts/:id 后端端点(UpdateCloudAccount handler + 路由注册)
- 前端 CloudAccountManager 添加编辑弹窗:复用创建表单,凭证留空保持不变
- 修复 scripts/mock.go 编译错误:补充缺失的 generateNotifyChannels 函数(5种通知渠道种子)
- 包含前序未提交的告警通知渠道 CRUD 后端API + AlertManager 通知配置前端UI
This commit is contained in:
attacker committed 2026-06-14 08:16:52 +08:00
1 parent 01a9bebb0c
commit af2e60acd9
9 files changed
+822 -51

No files matched your search

@@ -94,6 +94,19 @@ export function AlertManager() {
notify_channels: [] as string[],
});
// 通知渠道弹窗状态
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
const [notifyChannels, setNotifyChannels] = useState<any[]>([]);
const [editingChannel, setEditingChannel] = useState<any>(null);
const [channelSaving, setChannelSaving] = useState(false);
const [channelTesting, setChannelTesting] = useState<number | null>(null);
const [channelForm, setChannelForm] = useState({
name: '',
type: 'dingtalk',
config: '',
enabled: true,
});
const resetRuleForm = () => {
setRuleForm({
name: '',
@@ -176,6 +189,105 @@ export function AlertManager() {
}));
};
// ============ 通知渠道管理 ============
const CHANNEL_TYPES: { key: string; label: string; placeholder: string }[] = [
{ key: 'dingtalk', label: '钉钉', placeholder: '{\n "webhook": "https://oapi.dingtalk.com/robot/send?access_token=xxx",\n "secret": "SECxxx"\n}' },
{ key: 'wechat', label: '企业微信', placeholder: '{\n "webhook": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"\n}' },
{ key: 'email', label: '邮件', placeholder: '{\n "to": "ops@ezops.io",\n "subject": "告警通知"\n}' },
{ key: 'sms', label: '短信', placeholder: '{\n "phones": "13800138000",\n "sign": "EZOps"\n}' },
{ key: 'webhook', label: 'Webhook', placeholder: '{\n "url": "https://example.com/alert"\n}' },
];
const loadNotifyChannels = async () => {
try {
const data = await alertApi.getNotifyChannels();
setNotifyChannels((data as any)?.list || []);
} catch (error) {
console.error('Failed to load notify channels:', error);
}
};
const openChannelDialog = () => {
loadNotifyChannels();
setEditingChannel(null);
setChannelForm({ name: '', type: 'dingtalk', config: '', enabled: true });
setChannelDialogOpen(true);
};
const openEditChannel = (ch: any) => {
setEditingChannel(ch);
setChannelForm({
name: ch.name || '',
type: ch.type || 'dingtalk',
config: ch.config || '',
enabled: ch.enabled !== false,
});
};
const resetChannelForm = () => {
setEditingChannel(null);
setChannelForm({ name: '', type: 'dingtalk', config: '', enabled: true });
};
const handleSaveChannel = async () => {
if (!channelForm.name.trim()) return;
setChannelSaving(true);
try {
const payload = {
name: channelForm.name.trim(),
type: channelForm.type,
config: channelForm.config.trim(),
};
if (editingChannel) {
await alertApi.updateNotifyChannel(editingChannel.id, { ...payload, enabled: channelForm.enabled });
} else {
await alertApi.createNotifyChannel(payload);
}
resetChannelForm();
loadNotifyChannels();
} catch (error) {
console.error('Failed to save notify channel:', error);
} finally {
setChannelSaving(false);
}
};
const handleDeleteChannel = async (id: any) => {
if (!window.confirm('确定删除此通知渠道?')) return;
try {
await alertApi.deleteNotifyChannel(id);
if (editingChannel && editingChannel.id === id) resetChannelForm();
loadNotifyChannels();
} catch (error) {
console.error('Failed to delete notify channel:', error);
}
};
const handleToggleChannel = async (ch: any) => {
try {
await alertApi.updateNotifyChannel(ch.id, { enabled: !ch.enabled });
loadNotifyChannels();
} catch (error) {
console.error('Failed to toggle notify channel:', error);
}
};
const handleTestChannel = async (id: any) => {
setChannelTesting(id);
try {
await alertApi.testNotifyChannel(id);
loadNotifyChannels();
} catch (error) {
console.error('Failed to test notify channel:', error);
} finally {
setChannelTesting(null);
}
};
const getChannelTypeLabel = (type: string) => {
return CHANNEL_TYPES.find((t) => t.key === type)?.label || type;
};
const loadData = async () => {
try {
setLoading(true);
@@ -290,7 +402,7 @@ export function AlertManager() {
<p className="text-sm text-slate-500 mt-1">告警规则管理与告警事件处理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={openChannelDialog}>
<Settings className="h-4 w-4 mr-2" />
通知配置
</Button>
@@ -795,6 +907,162 @@ export function AlertManager() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 通知渠道管理弹窗 */}
<Dialog open={channelDialogOpen} onOpenChange={(open) => { setChannelDialogOpen(open); if (!open) resetChannelForm(); }}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-100 max-w-2xl">
<DialogHeader>
<DialogTitle className="text-slate-100">通知渠道配置</DialogTitle>
<DialogDescription className="text-slate-500">
管理告警通知渠道(钉钉/企业微信/邮件/短信/Webhook),配置后可在告警规则中引用
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 max-h-[65vh] overflow-y-auto pr-1">
{/* 渠道列表 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-slate-300 text-sm">已配置渠道</Label>
<Button
size="sm"
variant="outline"
className="h-7 border-slate-700 text-slate-300 hover:bg-slate-800 text-xs"
onClick={resetChannelForm}
>
<Plus className="h-3 w-3 mr-1" />
新建
</Button>
</div>
{notifyChannels.length === 0 ? (
<div className="text-center py-8 text-sm text-slate-600 border border-dashed border-slate-800 rounded-lg">
暂无通知渠道,请在右侧添加
</div>
) : (
<div className="space-y-2">
{notifyChannels.map((ch) => (
<div
key={ch.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
editingChannel && editingChannel.id === ch.id
? 'border-blue-500/50 bg-blue-500/5'
: 'border-slate-800 bg-slate-800/30 hover:border-slate-700'
}`}
onClick={() => openEditChannel(ch)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-slate-200 truncate">{ch.name}</span>
<Badge variant="outline" className="border-slate-600 text-slate-400 text-[10px] py-0">
{getChannelTypeLabel(ch.type)}
</Badge>
</div>
<Switch
checked={ch.enabled !== false}
onCheckedChange={() => handleToggleChannel(ch)}
onClick={(e) => e.stopPropagation()}
className="data-[state=checked]:bg-emerald-600 scale-90"
/>
</div>
<div className="flex items-center justify-between mt-2">
{ch.test_status === 'success' ? (
<span className="text-[11px] text-emerald-400">测试通过</span>
) : (
<span className="text-[11px] text-slate-600">未测试</span>
)}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 text-xs"
disabled={channelTesting === ch.id}
onClick={() => handleTestChannel(ch.id)}
>
{channelTesting === ch.id ? <Loader2 className="h-3 w-3 animate-spin" /> : '测试'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-red-400/70 hover:text-red-400 hover:bg-red-500/10 text-xs"
onClick={() => handleDeleteChannel(ch.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 渠道表单 */}
<div className="space-y-4">
<Label className="text-slate-300 text-sm">
{editingChannel ? '编辑渠道' : '新建渠道'}
</Label>
<div className="space-y-1.5">
<Label className="text-slate-400 text-xs">渠道名称 <span className="text-red-400">*</span></Label>
<Input
value={channelForm.name}
onChange={(e) => setChannelForm({ ...channelForm, name: e.target.value })}
placeholder="如:运维告警群"
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-400 text-xs">渠道类型</Label>
<select
value={channelForm.type}
onChange={(e) => setChannelForm({ ...channelForm, type: e.target.value })}
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
{CHANNEL_TYPES.map(({ key, label }) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label className="text-slate-400 text-xs">配置(JSON)</Label>
<Textarea
value={channelForm.config}
onChange={(e) => setChannelForm({ ...channelForm, config: e.target.value })}
placeholder={CHANNEL_TYPES.find((t) => t.key === channelForm.type)?.placeholder || ''}
rows={5}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 font-mono text-xs resize-none"
/>
</div>
{editingChannel && (
<div className="flex items-center gap-2">
<Switch
checked={channelForm.enabled}
onCheckedChange={(v) => setChannelForm({ ...channelForm, enabled: v })}
className="data-[state=checked]:bg-emerald-600"
/>
<Label className="text-slate-400 text-xs">启用此渠道</Label>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:bg-slate-800"
onClick={() => setChannelDialogOpen(false)}
>
关闭
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
disabled={!channelForm.name.trim() || channelSaving}
onClick={handleSaveChannel}
>
{channelSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingChannel ? '保存修改' : '添加渠道'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -22,7 +22,7 @@ import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner';
import { alertCenterApi } from '@/services/api';
import { alertCenterApi, alertApi } from '@/services/api';
interface Alert {
id: string;
@@ -59,11 +59,12 @@ interface AlertRule {
}
interface NotificationChannel {
id: string;
id: number;
name: string;
type: 'email' | 'slack' | 'webhook' | 'sms' | 'pagerduty';
type: string;
enabled: boolean;
config: Record<string, string>;
config: string;
test_status?: string;
}
const AlertCenter: React.FC = () => {
@@ -125,8 +126,9 @@ const AlertCenter: React.FC = () => {
const fetchChannels = async () => {
try {
const res = await alertCenterApi.getChannels();
setChannels(res.data || []);
const res = await alertApi.getNotifyChannels();
const list = (res as any)?.list || [];
setChannels(list);
} catch (error) {
toast.error('获取通知渠道失败');
}
@@ -173,6 +175,85 @@ const AlertCenter: React.FC = () => {
}
};
// 通知渠道管理
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<NotificationChannel | null>(null);
const [channelForm, setChannelForm] = useState({ name: '', type: 'dingtalk', config: '' });
const [channelSaving, setChannelSaving] = useState(false);
const openCreateChannel = () => {
setEditingChannel(null);
setChannelForm({ name: '', type: 'dingtalk', config: '' });
setChannelDialogOpen(true);
};
const openEditChannel = (ch: NotificationChannel) => {
setEditingChannel(ch);
setChannelForm({ name: ch.name, type: ch.type, config: ch.config || '' });
setChannelDialogOpen(true);
};
const handleSaveChannel = async () => {
if (!channelForm.name.trim()) return;
setChannelSaving(true);
try {
const payload = { name: channelForm.name.trim(), type: channelForm.type, config: channelForm.config.trim() };
if (editingChannel) {
await alertApi.updateNotifyChannel(editingChannel.id, payload);
toast.success('渠道已更新');
} else {
await alertApi.createNotifyChannel(payload);
toast.success('渠道已添加');
}
setChannelDialogOpen(false);
fetchChannels();
} catch (error) {
toast.error('保存失败');
} finally {
setChannelSaving(false);
}
};
const handleDeleteChannel = async (id: number) => {
if (!window.confirm('确定删除此通知渠道?')) return;
try {
await alertApi.deleteNotifyChannel(id);
toast.success('渠道已删除');
fetchChannels();
} catch (error) {
toast.error('删除失败');
}
};
const handleTestChannel = async (id: number) => {
try {
const res = await alertApi.testNotifyChannel(id);
const status = (res as any)?.status;
if (status === 'success') {
toast.success('测试通知已发送');
} else {
toast.warning('渠道未配置参数,请先完善配置');
}
fetchChannels();
} catch (error) {
toast.error('测试失败');
}
};
const handleToggleChannel = async (ch: NotificationChannel) => {
try {
await alertApi.updateNotifyChannel(ch.id, { enabled: !ch.enabled });
fetchChannels();
} catch (error) {
toast.error('操作失败');
}
};
const getChannelTypeLabel = (type: string) => {
const map: Record<string, string> = { dingtalk: '钉钉', wechat: '企业微信', email: '邮件', sms: '短信', webhook: 'Webhook' };
return map[type] || type;
};
const getSeverityBadge = (severity: string) => {
switch (severity) {
case 'critical':
@@ -489,43 +570,55 @@ const AlertCenter: React.FC = () => {
刷新
</Button>
</div>
<Button onClick={() => toast.info('添加渠道功能开发中')}>
<Button onClick={openCreateChannel}>
<Plus className="w-4 h-4 mr-2" />
添加渠道
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{channels.map((channel) => (
<Card key={channel.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{getChannelIcon(channel.type)}
<CardTitle className="text-base">{channel.name}</CardTitle>
{channels.length === 0 ? (
<div className="text-center py-16 text-muted-foreground border border-dashed rounded-lg">
暂无通知渠道,点击「添加渠道」创建
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{channels.map((channel) => (
<Card key={channel.id} className="cursor-pointer hover:border-primary/50 transition-colors" >
<CardHeader className="pb-3" onClick={() => openEditChannel(channel)}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{getChannelIcon(channel.type)}
<CardTitle className="text-base">{channel.name}</CardTitle>
</div>
<Badge variant={channel.enabled ? 'default' : 'secondary'} className={channel.enabled ? 'bg-green-600' : ''}>
{channel.enabled ? '已启用' : '已禁用'}
</Badge>
</div>
<Badge variant={channel.enabled ? 'default' : 'secondary'}>
{channel.enabled ? '已启用' : '已禁用'}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-sm text-muted-foreground capitalize mb-4">
类型: {channel.type}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1">
<Settings className="w-4 h-4 mr-2" />
配置
</Button>
<Button variant="outline" size="sm" className="flex-1">
<Zap className="w-4 h-4 mr-2" />
测试
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-sm text-muted-foreground mb-3">
类型: {getChannelTypeLabel(channel.type)}
{channel.test_status === 'success' && (
<span className="ml-2 text-green-500">测试通过</span>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleTestChannel(channel.id)}>
<Zap className="w-4 h-4 mr-1.5" />
测试
</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleToggleChannel(channel)}>
<Settings className="w-4 h-4 mr-1.5" />
{channel.enabled ? '禁用' : '启用'}
</Button>
<Button variant="outline" size="sm" onClick={() => handleDeleteChannel(channel.id)}>
<X className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
@@ -624,6 +717,63 @@ const AlertCenter: React.FC = () => {
</div>
</DialogContent>
</Dialog>
{/* 通知渠道创建/编辑对话框 */}
<Dialog open={channelDialogOpen} onOpenChange={setChannelDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingChannel ? '编辑通知渠道' : '添加通知渠道'}</DialogTitle>
<DialogDescription>配置告警通知渠道(钉钉/企业微信/邮件/短信/Webhook)</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>渠道名称 *</Label>
<Input
value={channelForm.name}
onChange={(e) => setChannelForm({ ...channelForm, name: e.target.value })}
placeholder="如:运维告警群"
/>
</div>
<div className="space-y-2">
<Label>渠道类型</Label>
<Select value={channelForm.type} onValueChange={(v) => setChannelForm({ ...channelForm, type: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dingtalk">钉钉</SelectItem>
<SelectItem value="wechat">企业微信</SelectItem>
<SelectItem value="email">邮件</SelectItem>
<SelectItem value="sms">短信</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>配置(JSON)</Label>
<Input
value={channelForm.config}
onChange={(e) => setChannelForm({ ...channelForm, config: e.target.value })}
placeholder='{"webhook":"https://...","secret":"..."}'
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{channelForm.type === 'dingtalk' && '钉钉机器人: webhook URL + secret'}
{channelForm.type === 'wechat' && '企业微信: webhook URL'}
{channelForm.type === 'email' && '邮件: {"to":"ops@ezops.io"}'}
{channelForm.type === 'sms' && '短信: {"phones":"13800138000"}'}
{channelForm.type === 'webhook' && 'Webhook: {"url":"https://..."}'}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setChannelDialogOpen(false)}>取消</Button>
<Button onClick={handleSaveChannel} disabled={!channelForm.name.trim() || channelSaving}>
{channelSaving ? '保存中...' : editingChannel ? '保存修改' : '添加渠道'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};