feat: 网络双线图+告警联系人/联系组+Agent v2.0.0 + 一键报警阈值
- HostMonitor: 网络改为双线图 rx/tx 网卡带宽吞吐(双Area线), 移除单rx图 - Agent: 默认版本号从 dev 改为 v2.0.0, 重建二进制 5.2MB - 告警中心: 新增报警联系人/报警联系组 Tab, ContactDialog/ContactGroupDialog - 后端: AlertContact/AlertContactGroup 模型+CRUD, monitor_alert_contacts/groups 表 - 路由: /alerts/contacts + /alerts/contact-groups 8个REST端点 - 一键报警: 更新描述为6条规则(含TCP+进程), 切换到 /alerts/quick-setup - 测试规则: 内存>10% 触发告警已创建, 飞书通知验证通过
This commit is contained in:
1 parent
5333f6f04a
commit
e1cfbaf45e
11 files changed
+810
-16
No files matched your search
@@ -7,7 +7,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
@@ -15,13 +15,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertCenterApi, agentApi } from '@/services/api';
|
||||
import { alertCenterApi, agentApi, alertApi } from '@/services/api';
|
||||
import {
|
||||
type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge,
|
||||
} from './alert-center-helpers';
|
||||
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
||||
import { AlertChannelsTab } from './AlertChannelsTab';
|
||||
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
|
||||
import { ContactDialog } from './ContactDialog';
|
||||
import { ContactGroupDialog } from './ContactGroupDialog';
|
||||
import type { Contact, ContactGroup } from './alert-center-helpers';
|
||||
|
||||
const AlertCenter: React.FC = () => {
|
||||
const _confirm = useConfirm();
|
||||
@@ -40,6 +43,13 @@ const AlertCenter: React.FC = () => {
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<any>(null);
|
||||
const [quickSetupLoading, setQuickSetupLoading] = useState(false);
|
||||
// 联系人 & 联系组
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [contactGroups, setContactGroups] = useState<ContactGroup[]>([]);
|
||||
const [contactDialogOpen, setContactDialogOpen] = useState(false);
|
||||
const [editingContact, setEditingContact] = useState<Contact | null>(null);
|
||||
const [groupDialogOpen, setGroupDialogOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<ContactGroup | null>(null);
|
||||
// 分页
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
@@ -48,9 +58,11 @@ const AlertCenter: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
if (activeTab === 'contacts') fetchContacts();
|
||||
if (activeTab === 'groups') { fetchContacts(); fetchContactGroups(); }
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [page, severityFilter, statusFilter]);
|
||||
}, [page, severityFilter, statusFilter, activeTab]);
|
||||
|
||||
const fetchData = async () => {
|
||||
await Promise.all([fetchAlerts(), fetchRules()]);
|
||||
@@ -103,6 +115,20 @@ const AlertCenter: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchContacts = async () => {
|
||||
try {
|
||||
const res: any = await alertApi.getContacts();
|
||||
setContacts((res?.data?.list || res?.list || []) as Contact[]);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const fetchContactGroups = async () => {
|
||||
try {
|
||||
const res: any = await alertApi.getContactGroups();
|
||||
setContactGroups((res?.data?.list || res?.list || []) as ContactGroup[]);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const handleAcknowledge = async (id: string) => {
|
||||
try {
|
||||
await alertCenterApi.acknowledge(id);
|
||||
@@ -151,13 +177,13 @@ const AlertCenter: React.FC = () => {
|
||||
const handleQuickSetup = async () => {
|
||||
const ok = await _confirm({
|
||||
title: '一键报警',
|
||||
description: '将为 Agent 在线的全部主机创建 CPU使用率、内存使用率、磁盘使用率、网络带宽使用率 四条默认告警规则(阈值 80%,连续 5 次触发)。',
|
||||
description: '为 Agent 在线主机创建 CPU(>80%)/内存(>80%)/磁盘(>90%)/TIME_WAIT(>15000)/CLOSE_WAIT(>200)/进程检测 6 条告警规则。',
|
||||
confirmText: '确认创建',
|
||||
});
|
||||
if (!ok) return;
|
||||
setQuickSetupLoading(true);
|
||||
try {
|
||||
const res = await agentApi.quickSetup('ecs');
|
||||
const res = await alertCenterApi.quickSetup('ecs');
|
||||
toast.success(typeof res === 'string' ? res : (res?.message || '默认告警规则创建成功'));
|
||||
fetchRules();
|
||||
} catch {
|
||||
@@ -236,10 +262,12 @@ const AlertCenter: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3 lg:w-[360px] h-9">
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-[600px] h-9">
|
||||
<TabsTrigger value="alerts" className="text-xs">告警列表</TabsTrigger>
|
||||
<TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger>
|
||||
<TabsTrigger value="channels" className="text-xs">通知渠道</TabsTrigger>
|
||||
<TabsTrigger value="contacts" className="text-xs">报警联系人</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="text-xs">报警联系组</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 告警列表 Tab */}
|
||||
@@ -478,6 +506,121 @@ const AlertCenter: React.FC = () => {
|
||||
<TabsContent value="channels">
|
||||
<AlertChannelsTab refreshKey={0} />
|
||||
</TabsContent>
|
||||
|
||||
{/* 报警联系人 Tab */}
|
||||
<TabsContent value="contacts" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button variant="outline" onClick={fetchContacts}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => { setEditingContact(null); setContactDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加联系人
|
||||
</Button>
|
||||
</div>
|
||||
{contacts.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">
|
||||
{contacts.map((c) => (
|
||||
<Card key={c.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-base">{c.name}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-1 text-sm text-muted-foreground">
|
||||
{c.phone && <div>📱 {c.phone}</div>}
|
||||
{c.email && <div>📧 {c.email}</div>}
|
||||
{c.dingtalk && <div>🔷 钉钉已配置</div>}
|
||||
{c.feishu && <div>🟢 飞书已配置</div>}
|
||||
{c.wechat && <div>💚 企业微信已配置</div>}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1"
|
||||
onClick={() => { setEditingContact(c); setContactDialogOpen(true); }}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={async () => {
|
||||
const ok = await _confirm({ title: '删除联系人', description: `确定删除联系人「${c.name}」?`, confirmText: '删除', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await alertApi.deleteContact(c.id); toast.success('已删除'); fetchContacts(); }
|
||||
catch { toast.error('删除失败'); }
|
||||
}}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 报警联系组 Tab */}
|
||||
<TabsContent value="groups" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button variant="outline" onClick={fetchContactGroups}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => { setEditingGroup(null); setGroupDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加联系组
|
||||
</Button>
|
||||
</div>
|
||||
{contactGroups.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">
|
||||
{contactGroups.map((g) => {
|
||||
let cids: number[] = [];
|
||||
try { cids = JSON.parse(g.contact_ids || '[]'); } catch { /* ignore */ }
|
||||
const memberNames = contacts
|
||||
.filter(c => cids.includes(c.id))
|
||||
.map(c => c.name);
|
||||
return (
|
||||
<Card key={g.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-base">{g.group_name}</CardTitle>
|
||||
<Badge variant="secondary">{cids.length} 人</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
{g.notes && <p className="text-sm text-muted-foreground">{g.notes}</p>}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{memberNames.map(name => (
|
||||
<Badge key={name} variant="outline" className="text-xs">{name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1"
|
||||
onClick={() => { setEditingGroup(g); setGroupDialogOpen(true); }}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={async () => {
|
||||
const ok = await _confirm({ title: '删除联系组', description: `确定删除联系组「${g.group_name}」?`, confirmText: '删除', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await alertApi.deleteContactGroup(g.id); toast.success('已删除'); fetchContactGroups(); }
|
||||
catch { toast.error('删除失败'); }
|
||||
}}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AlertDetailDialog
|
||||
@@ -499,6 +642,19 @@ const AlertCenter: React.FC = () => {
|
||||
notifyChannels={[]}
|
||||
onSaved={() => { handleRuleDialogClose(); fetchRules(); }}
|
||||
/>
|
||||
<ContactDialog
|
||||
open={contactDialogOpen}
|
||||
onOpenChange={setContactDialogOpen}
|
||||
editingContact={editingContact}
|
||||
onSaved={fetchContacts}
|
||||
/>
|
||||
<ContactGroupDialog
|
||||
open={groupDialogOpen}
|
||||
onOpenChange={setGroupDialogOpen}
|
||||
editingGroup={editingGroup}
|
||||
contacts={contacts}
|
||||
onSaved={() => { fetchContactGroups(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// 报警联系人创建/编辑对话框 // Logan.Li
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import { alertApi } from '@/services/api';
|
||||
import type { Contact } from './alert-center-helpers';
|
||||
|
||||
export function ContactDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingContact,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingContact: Contact | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
name: '', phone: '', email: '', dingtalk: '', feishu: '', wechat: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (editingContact) {
|
||||
setForm({
|
||||
name: editingContact.name || '',
|
||||
phone: editingContact.phone || '',
|
||||
email: editingContact.email || '',
|
||||
dingtalk: editingContact.dingtalk || '',
|
||||
feishu: editingContact.feishu || '',
|
||||
wechat: editingContact.wechat || '',
|
||||
});
|
||||
} else {
|
||||
setForm({ name: '', phone: '', email: '', dingtalk: '', feishu: '', wechat: '' });
|
||||
}
|
||||
}
|
||||
}, [open, editingContact]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
dingtalk: form.dingtalk.trim() || undefined,
|
||||
feishu: form.feishu.trim() || undefined,
|
||||
wechat: form.wechat.trim() || undefined,
|
||||
};
|
||||
if (editingContact) {
|
||||
await alertApi.updateContact(editingContact.id, payload);
|
||||
toast.success('联系人已更新');
|
||||
} else {
|
||||
await alertApi.createContact(payload);
|
||||
toast.success('联系人已添加');
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingContact ? '编辑报警联系人' : '添加报警联系人'}</DialogTitle>
|
||||
<DialogDescription>配置报警联系人信息(姓名必填,渠道选填)</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>姓名 *</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="如:张三"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder="如:13800138000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱</Label>
|
||||
<Input
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
placeholder="如:zhangsan@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>钉钉(webhook URL)</Label>
|
||||
<Input
|
||||
value={form.dingtalk}
|
||||
onChange={(e) => setForm({ ...form, dingtalk: e.target.value })}
|
||||
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>飞书(webhook URL)</Label>
|
||||
<Input
|
||||
value={form.feishu}
|
||||
onChange={(e) => setForm({ ...form, feishu: e.target.value })}
|
||||
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>企业微信(webhook URL)</Label>
|
||||
<Input
|
||||
value={form.wechat}
|
||||
onChange={(e) => setForm({ ...form, wechat: e.target.value })}
|
||||
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={!form.name.trim() || saving}>
|
||||
{saving ? '保存中...' : editingContact ? '保存修改' : '添加联系人'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 报警联系组创建/编辑对话框 // Logan.Li
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { alertApi } from '@/services/api';
|
||||
import type { Contact, ContactGroup } from './alert-center-helpers';
|
||||
|
||||
export function ContactGroupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingGroup,
|
||||
contacts,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingGroup: ContactGroup | null;
|
||||
contacts: Contact[];
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({ group_name: '', notes: '', contact_ids: [] as number[] });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (editingGroup) {
|
||||
let existingIDs: number[] = [];
|
||||
try {
|
||||
existingIDs = JSON.parse(editingGroup.contact_ids || '[]');
|
||||
} catch { /* ignore */ }
|
||||
setForm({
|
||||
group_name: editingGroup.group_name || '',
|
||||
notes: editingGroup.notes || '',
|
||||
contact_ids: existingIDs,
|
||||
});
|
||||
} else {
|
||||
setForm({ group_name: '', notes: '', contact_ids: [] });
|
||||
}
|
||||
}
|
||||
}, [open, editingGroup]);
|
||||
|
||||
const toggleContact = (id: number) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
contact_ids: prev.contact_ids.includes(id)
|
||||
? prev.contact_ids.filter(cid => cid !== id)
|
||||
: [...prev.contact_ids, id],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.group_name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
group_name: form.group_name.trim(),
|
||||
notes: form.notes.trim() || undefined,
|
||||
contact_ids: form.contact_ids,
|
||||
};
|
||||
if (editingGroup) {
|
||||
await alertApi.updateContactGroup(editingGroup.id, payload);
|
||||
toast.success('联系组已更新');
|
||||
} else {
|
||||
await alertApi.createContactGroup(payload);
|
||||
toast.success('联系组已添加');
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingGroup ? '编辑报警联系组' : '添加报警联系组'}</DialogTitle>
|
||||
<DialogDescription>配置报警联系组,选择组内成员</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>组名称 *</Label>
|
||||
<Input
|
||||
value={form.group_name}
|
||||
onChange={(e) => setForm({ ...form, group_name: e.target.value })}
|
||||
placeholder="如:运维值班组"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>备注</Label>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="如:周一至周五 9:00-21:00"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>选择联系人</Label>
|
||||
{contacts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">暂无联系人,请先在「报警联系人」标签页添加</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 max-h-[180px] overflow-y-auto border rounded-md p-2">
|
||||
{contacts.map(c => (
|
||||
<Badge
|
||||
key={c.id}
|
||||
variant={form.contact_ids.includes(c.id) ? 'default' : 'outline'}
|
||||
className="cursor-pointer text-xs"
|
||||
onClick={() => toggleContact(c.id)}
|
||||
>
|
||||
{c.name}
|
||||
{form.contact_ids.includes(c.id) && ' ✓'}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{form.contact_ids.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">已选择 {form.contact_ids.length} 位联系人</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={!form.group_name.trim() || saving}>
|
||||
{saving ? '保存中...' : editingGroup ? '保存修改' : '添加联系组'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,27 @@ export interface NotificationChannel {
|
||||
test_status?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id: number;
|
||||
name: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
dingtalk?: string;
|
||||
feishu?: string;
|
||||
wechat?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ContactGroup {
|
||||
id: number;
|
||||
group_name: string;
|
||||
notes?: string;
|
||||
contact_ids?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Logan.Li — 后端字段映射到前端 Alert 接口
|
||||
export function mapAlert(a: any): Alert {
|
||||
return {
|
||||
|
||||
@@ -272,14 +272,22 @@ export function HostMonitor() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 网络监控 — 增加 TIME_WAIT + FD 图表 */}
|
||||
<TabsContent value="network" className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<MetricChartCard title="TCP 连接数(EST)" value={currentVal('tcp')} icon={<Network className="h-4 w-4 text-purple-400" />}
|
||||
data={metrics.tcpEst} color="#8b5cf6" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="TCP TIME_WAIT" value={currentVal('tw')} icon={<Clock className="h-4 w-4 text-amber-400" />}
|
||||
data={metrics.tcpTW} color="#f59e0b" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="网络接收(KB/s)" value={currentVal('rx')} icon={<Network className="h-4 w-4 text-blue-400" />}
|
||||
data={metrics.rx} color="#3b82f6" unit="KB/s" domain={[0, 'auto']} aggType={aggType} />
|
||||
{/* 网络监控 — 双线网卡带宽 + TIME_WAIT + TCP连接 */}
|
||||
<TabsContent value="network" className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<MetricChartCard title="TCP 连接数(EST)" value={currentVal('tcp')} icon={<Network className="h-4 w-4 text-purple-400" />}
|
||||
data={metrics.tcpEst} color="#8b5cf6" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
<MetricChartCard title="TCP TIME_WAIT" value={currentVal('tw')} icon={<Clock className="h-4 w-4 text-amber-400" />}
|
||||
data={metrics.tcpTW} color="#f59e0b" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
</div>
|
||||
{/* 网卡带宽吞吐 — 双线 rx/tx */}
|
||||
<DualLineChart
|
||||
title="网卡带宽吞吐"
|
||||
currentRx={netInfo?.net_rx_bps != null ? formatBPS(netInfo.net_rx_bps) : '--'}
|
||||
currentTx={netInfo?.net_tx_bps != null ? formatBPS(netInfo.net_tx_bps) : '--'}
|
||||
rxData={metrics.rx} txData={metrics.tx}
|
||||
aggType={aggType} unit="KB/s"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══ 进程监控 — v2.0 新增 ═══ */}
|
||||
@@ -505,3 +513,68 @@ function MetricChartCard({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════ 双线图表(网卡带宽 rx+tx)═══════
|
||||
function DualLineChart({
|
||||
title, currentRx, currentTx, rxData, txData, aggType, unit,
|
||||
}: {
|
||||
title: string; currentRx: string; currentTx: string;
|
||||
rxData: BucketPoint[]; txData: BucketPoint[];
|
||||
aggType: AggType; unit: string;
|
||||
}) {
|
||||
// 合并 rx/tx 为统一时间序列
|
||||
const chartData = rxData.map((d, i) => ({
|
||||
time: d.time,
|
||||
timestamp: d.timestamp,
|
||||
rx: d[aggType] ?? 0,
|
||||
tx: txData[i]?.[aggType] ?? 0,
|
||||
}));
|
||||
const rxGradId = `grad-rx-${title.replace(/\s/g, '')}`;
|
||||
const txGradId = `grad-tx-${title.replace(/\s/g, '')}`;
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Network className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-xs text-[var(--dg-text-primary)] font-medium">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-blue-400 mr-1 align-middle" />接收 <b className="text-blue-300">{currentRx}</b></span>
|
||||
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-emerald-400 mr-1 align-middle" />发送 <b className="text-emerald-300">{currentTx}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-56">
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
|
||||
<defs>
|
||||
<linearGradient id={rxGradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id={txGradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
|
||||
<XAxis dataKey="time" hide />
|
||||
<YAxis hide domain={[0, 'auto']} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
|
||||
formatter={(v: number, name: string) => [`${v.toFixed(2)} ${unit}`, name === 'rx' ? '接收' : '发送']}
|
||||
labelFormatter={(label) => `时间: ${label}`} />
|
||||
<Area type="monotone" dataKey="rx" stroke="#3b82f6" strokeWidth={1.5} fill={`url(#${rxGradId})`} name="rx" />
|
||||
<Area type="monotone" dataKey="tx" stroke="#10b981" strokeWidth={1.5} fill={`url(#${txGradId})`} name="tx" />
|
||||
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
|
||||
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -576,6 +576,32 @@ export const alertApi = {
|
||||
testNotifyChannel: async (id: number | string) => {
|
||||
return http.post(`/alerts/notify-channels/${id}/test`);
|
||||
},
|
||||
// 报警联系人
|
||||
getContacts: async () => {
|
||||
return http.get('/alerts/contacts');
|
||||
},
|
||||
createContact: async (data: any) => {
|
||||
return http.post('/alerts/contacts', data);
|
||||
},
|
||||
updateContact: async (id: number | string, data: any) => {
|
||||
return http.put(`/alerts/contacts/${id}`, data);
|
||||
},
|
||||
deleteContact: async (id: number | string) => {
|
||||
return http.delete(`/alerts/contacts/${id}`);
|
||||
},
|
||||
// 报警联系组
|
||||
getContactGroups: async () => {
|
||||
return http.get('/alerts/contact-groups');
|
||||
},
|
||||
createContactGroup: async (data: any) => {
|
||||
return http.post('/alerts/contact-groups', data);
|
||||
},
|
||||
updateContactGroup: async (id: number | string, data: any) => {
|
||||
return http.put(`/alerts/contact-groups/${id}`, data);
|
||||
},
|
||||
deleteContactGroup: async (id: number | string) => {
|
||||
return http.delete(`/alerts/contact-groups/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 流水线 API ====================
|
||||
|
||||
Reference in new issue
Block a user