feat: 云账号编辑功能 + 通知渠道种子数据 + 修复mock.go编译错误
- 新增 PUT /cloud-accounts/:id 后端端点(UpdateCloudAccount handler + 路由注册) - 前端 CloudAccountManager 添加编辑弹窗:复用创建表单,凭证留空保持不变 - 修复 scripts/mock.go 编译错误:补充缺失的 generateNotifyChannels 函数(5种通知渠道种子) - 包含前序未提交的告警通知渠道 CRUD 后端API + AlertManager 通知配置前端UI
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user