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
+806
-12
No files matched your search
+1
-1
@@ -32,7 +32,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
version = "dev"
|
version = "v2.0.0"
|
||||||
buildTime = "unknown"
|
buildTime = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,151 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"ezops/internal/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 告警联系人 + 联系组 CRUD(AlertHandler 方法接收者) // Logan.Li
|
||||||
|
|
||||||
|
// ==================== 联系人 ====================
|
||||||
|
|
||||||
|
// ListContacts GET /alerts/contacts
|
||||||
|
func (h *AlertHandler) ListContacts(c *gin.Context) {
|
||||||
|
var contacts []models.AlertContact
|
||||||
|
h.db.Order("created_at DESC").Find(&contacts)
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "success",
|
||||||
|
Data: models.ListResponse{List: contacts, Total: int64(len(contacts))},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateContact POST /alerts/contacts
|
||||||
|
func (h *AlertHandler) CreateContact(c *gin.Context) {
|
||||||
|
var req models.CreateContactRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contact := models.AlertContact{
|
||||||
|
Name: req.Name,
|
||||||
|
Phone: req.Phone,
|
||||||
|
Email: req.Email,
|
||||||
|
Dingtalk: req.Dingtalk,
|
||||||
|
Feishu: req.Feishu,
|
||||||
|
Wechat: req.Wechat,
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&contact).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: contact})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateContact PUT /alerts/contacts/:id
|
||||||
|
func (h *AlertHandler) UpdateContact(c *gin.Context) {
|
||||||
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
var req models.UpdateContactRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var contact models.AlertContact
|
||||||
|
if err := h.db.First(&contact, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "联系人不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if req.Name != "" { updates["name"] = req.Name }
|
||||||
|
if req.Phone != "" { updates["phone"] = req.Phone }
|
||||||
|
if req.Email != "" { updates["email"] = req.Email }
|
||||||
|
if req.Dingtalk != "" { updates["dingtalk"] = req.Dingtalk }
|
||||||
|
if req.Feishu != "" { updates["feishu"] = req.Feishu }
|
||||||
|
if req.Wechat != "" { updates["wechat"] = req.Wechat }
|
||||||
|
h.db.Model(&contact).Updates(updates)
|
||||||
|
h.db.First(&contact, id)
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: contact})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteContact DELETE /alerts/contacts/:id
|
||||||
|
func (h *AlertHandler) DeleteContact(c *gin.Context) {
|
||||||
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
if err := h.db.Delete(&models.AlertContact{}, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 联系组 ====================
|
||||||
|
|
||||||
|
// ListContactGroups GET /alerts/contact-groups
|
||||||
|
func (h *AlertHandler) ListContactGroups(c *gin.Context) {
|
||||||
|
var groups []models.AlertContactGroup
|
||||||
|
h.db.Order("created_at DESC").Find(&groups)
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "success",
|
||||||
|
Data: models.ListResponse{List: groups, Total: int64(len(groups))},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateContactGroup POST /alerts/contact-groups
|
||||||
|
func (h *AlertHandler) CreateContactGroup(c *gin.Context) {
|
||||||
|
var req models.CreateContactGroupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idsJSON, _ := json.Marshal(req.ContactIDs)
|
||||||
|
group := models.AlertContactGroup{
|
||||||
|
GroupName: req.GroupName,
|
||||||
|
Notes: req.Notes,
|
||||||
|
ContactIDs: string(idsJSON),
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&group).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: group})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateContactGroup PUT /alerts/contact-groups/:id
|
||||||
|
func (h *AlertHandler) UpdateContactGroup(c *gin.Context) {
|
||||||
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
var req models.UpdateContactGroupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var group models.AlertContactGroup
|
||||||
|
if err := h.db.First(&group, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "联系组不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if req.GroupName != "" { updates["group_name"] = req.GroupName }
|
||||||
|
if req.Notes != "" { updates["notes"] = req.Notes }
|
||||||
|
if len(req.ContactIDs) > 0 {
|
||||||
|
idsJSON, _ := json.Marshal(req.ContactIDs)
|
||||||
|
updates["contact_ids"] = string(idsJSON)
|
||||||
|
}
|
||||||
|
h.db.Model(&group).Updates(updates)
|
||||||
|
h.db.First(&group, id)
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: group})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteContactGroup DELETE /alerts/contact-groups/:id
|
||||||
|
func (h *AlertHandler) DeleteContactGroup(c *gin.Context) {
|
||||||
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
if err := h.db.Delete(&models.AlertContactGroup{}, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
|
||||||
|
}
|
||||||
@@ -455,6 +455,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
alerts.DELETE("/notify-channels/:id", alertHandler.DeleteNotifyChannel)
|
alerts.DELETE("/notify-channels/:id", alertHandler.DeleteNotifyChannel)
|
||||||
alerts.POST("/notify-channels/:id/test", alertHandler.TestNotifyChannel)
|
alerts.POST("/notify-channels/:id/test", alertHandler.TestNotifyChannel)
|
||||||
alerts.POST("/quick-setup", alertHandler.QuickSetupRules)
|
alerts.POST("/quick-setup", alertHandler.QuickSetupRules)
|
||||||
|
// 报警联系人
|
||||||
|
alerts.GET("/contacts", alertHandler.ListContacts)
|
||||||
|
alerts.POST("/contacts", alertHandler.CreateContact)
|
||||||
|
alerts.PUT("/contacts/:id", alertHandler.UpdateContact)
|
||||||
|
alerts.DELETE("/contacts/:id", alertHandler.DeleteContact)
|
||||||
|
// 报警联系组
|
||||||
|
alerts.GET("/contact-groups", alertHandler.ListContactGroups)
|
||||||
|
alerts.POST("/contact-groups", alertHandler.CreateContactGroup)
|
||||||
|
alerts.PUT("/contact-groups/:id", alertHandler.UpdateContactGroup)
|
||||||
|
alerts.DELETE("/contact-groups/:id", alertHandler.DeleteContactGroup)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流水线路由
|
// 流水线路由
|
||||||
|
|||||||
@@ -395,6 +395,79 @@ type AcknowledgeAlertRequest struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AlertContact 报警联系人
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
type AlertContact struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
|
||||||
|
Name string `gorm:"type:varchar(128);not null;comment:联系人姓名" json:"name"`
|
||||||
|
Phone string `gorm:"type:varchar(32);comment:手机号" json:"phone,omitempty"`
|
||||||
|
Email string `gorm:"type:varchar(128);comment:邮箱" json:"email,omitempty"`
|
||||||
|
Dingtalk string `gorm:"type:varchar(256);comment:钉钉webhook地址" json:"dingtalk,omitempty"`
|
||||||
|
Feishu string `gorm:"type:varchar(256);comment:飞书webhook地址" json:"feishu,omitempty"`
|
||||||
|
Wechat string `gorm:"type:varchar(256);comment:企业微信webhook地址" json:"wechat,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AlertContact) TableName() string {
|
||||||
|
return "monitor_alert_contacts"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AlertContactGroup 报警联系组
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
type AlertContactGroup struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
|
||||||
|
GroupName string `gorm:"type:varchar(128);not null;comment:组名称" json:"group_name"`
|
||||||
|
Notes string `gorm:"type:varchar(500);comment:备注" json:"notes,omitempty"`
|
||||||
|
ContactIDs string `gorm:"type:text;comment:联系人ID列表JSON" json:"contact_ids,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AlertContactGroup) TableName() string {
|
||||||
|
return "monitor_alert_contact_groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateContactRequest 创建联系人请求
|
||||||
|
type CreateContactRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Dingtalk string `json:"dingtalk,omitempty"`
|
||||||
|
Feishu string `json:"feishu,omitempty"`
|
||||||
|
Wechat string `json:"wechat,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateContactRequest 更新联系人请求
|
||||||
|
type UpdateContactRequest struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Dingtalk string `json:"dingtalk,omitempty"`
|
||||||
|
Feishu string `json:"feishu,omitempty"`
|
||||||
|
Wechat string `json:"wechat,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateContactGroupRequest 创建联系组请求
|
||||||
|
type CreateContactGroupRequest struct {
|
||||||
|
GroupName string `json:"group_name" binding:"required"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
ContactIDs []uint `json:"contact_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateContactGroupRequest 更新联系组请求
|
||||||
|
type UpdateContactGroupRequest struct {
|
||||||
|
GroupName string `json:"group_name,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
ContactIDs []uint `json:"contact_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// CreateNotifyChannelRequest 创建通知渠道请求
|
// CreateNotifyChannelRequest 创建通知渠道请求
|
||||||
type CreateNotifyChannelRequest struct {
|
type CreateNotifyChannelRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
@@ -426,7 +499,7 @@ type FeishuCardContent struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content [][]FeishuCardElement `json:"content"`
|
Content [][]FeishuCardElement `json:"content"`
|
||||||
}
|
}
|
||||||
|
// FeishuCardElement 飞书卡片元素
|
||||||
type FeishuCardElement struct {
|
type FeishuCardElement struct {
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
@@ -15,13 +15,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||||
import { alertCenterApi, agentApi } from '@/services/api';
|
import { alertCenterApi, agentApi, alertApi } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge,
|
type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge,
|
||||||
} from './alert-center-helpers';
|
} from './alert-center-helpers';
|
||||||
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
||||||
import { AlertChannelsTab } from './AlertChannelsTab';
|
import { AlertChannelsTab } from './AlertChannelsTab';
|
||||||
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
|
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 AlertCenter: React.FC = () => {
|
||||||
const _confirm = useConfirm();
|
const _confirm = useConfirm();
|
||||||
@@ -40,6 +43,13 @@ const AlertCenter: React.FC = () => {
|
|||||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||||
const [editingRule, setEditingRule] = useState<any>(null);
|
const [editingRule, setEditingRule] = useState<any>(null);
|
||||||
const [quickSetupLoading, setQuickSetupLoading] = useState(false);
|
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 [page, setPage] = useState(1);
|
||||||
const [pageSize] = useState(10);
|
const [pageSize] = useState(10);
|
||||||
@@ -48,9 +58,11 @@ const AlertCenter: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
|
if (activeTab === 'contacts') fetchContacts();
|
||||||
|
if (activeTab === 'groups') { fetchContacts(); fetchContactGroups(); }
|
||||||
const interval = setInterval(fetchData, 30000);
|
const interval = setInterval(fetchData, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [page, severityFilter, statusFilter]);
|
}, [page, severityFilter, statusFilter, activeTab]);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
await Promise.all([fetchAlerts(), fetchRules()]);
|
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) => {
|
const handleAcknowledge = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await alertCenterApi.acknowledge(id);
|
await alertCenterApi.acknowledge(id);
|
||||||
@@ -151,13 +177,13 @@ const AlertCenter: React.FC = () => {
|
|||||||
const handleQuickSetup = async () => {
|
const handleQuickSetup = async () => {
|
||||||
const ok = await _confirm({
|
const ok = await _confirm({
|
||||||
title: '一键报警',
|
title: '一键报警',
|
||||||
description: '将为 Agent 在线的全部主机创建 CPU使用率、内存使用率、磁盘使用率、网络带宽使用率 四条默认告警规则(阈值 80%,连续 5 次触发)。',
|
description: '为 Agent 在线主机创建 CPU(>80%)/内存(>80%)/磁盘(>90%)/TIME_WAIT(>15000)/CLOSE_WAIT(>200)/进程检测 6 条告警规则。',
|
||||||
confirmText: '确认创建',
|
confirmText: '确认创建',
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setQuickSetupLoading(true);
|
setQuickSetupLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await agentApi.quickSetup('ecs');
|
const res = await alertCenterApi.quickSetup('ecs');
|
||||||
toast.success(typeof res === 'string' ? res : (res?.message || '默认告警规则创建成功'));
|
toast.success(typeof res === 'string' ? res : (res?.message || '默认告警规则创建成功'));
|
||||||
fetchRules();
|
fetchRules();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -236,10 +262,12 @@ const AlertCenter: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<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="alerts" className="text-xs">告警列表</TabsTrigger>
|
||||||
<TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger>
|
<TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger>
|
||||||
<TabsTrigger value="channels" 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>
|
</TabsList>
|
||||||
|
|
||||||
{/* 告警列表 Tab */}
|
{/* 告警列表 Tab */}
|
||||||
@@ -478,6 +506,121 @@ const AlertCenter: React.FC = () => {
|
|||||||
<TabsContent value="channels">
|
<TabsContent value="channels">
|
||||||
<AlertChannelsTab refreshKey={0} />
|
<AlertChannelsTab refreshKey={0} />
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
<AlertDetailDialog
|
<AlertDetailDialog
|
||||||
@@ -499,6 +642,19 @@ const AlertCenter: React.FC = () => {
|
|||||||
notifyChannels={[]}
|
notifyChannels={[]}
|
||||||
onSaved={() => { handleRuleDialogClose(); fetchRules(); }}
|
onSaved={() => { handleRuleDialogClose(); fetchRules(); }}
|
||||||
/>
|
/>
|
||||||
|
<ContactDialog
|
||||||
|
open={contactDialogOpen}
|
||||||
|
onOpenChange={setContactDialogOpen}
|
||||||
|
editingContact={editingContact}
|
||||||
|
onSaved={fetchContacts}
|
||||||
|
/>
|
||||||
|
<ContactGroupDialog
|
||||||
|
open={groupDialogOpen}
|
||||||
|
onOpenChange={setGroupDialogOpen}
|
||||||
|
editingGroup={editingGroup}
|
||||||
|
contacts={contacts}
|
||||||
|
onSaved={() => { fetchContactGroups(); }}
|
||||||
|
/>
|
||||||
</div>
|
</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;
|
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 接口
|
// Logan.Li — 后端字段映射到前端 Alert 接口
|
||||||
export function mapAlert(a: any): Alert {
|
export function mapAlert(a: any): Alert {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -272,14 +272,22 @@ export function HostMonitor() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 网络监控 — 增加 TIME_WAIT + FD 图表 */}
|
{/* 网络监控 — 双线网卡带宽 + TIME_WAIT + TCP连接 */}
|
||||||
<TabsContent value="network" className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
|
<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" />}
|
<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} />
|
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" />}
|
<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} />
|
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" />}
|
</div>
|
||||||
data={metrics.rx} color="#3b82f6" unit="KB/s" domain={[0, 'auto']} aggType={aggType} />
|
{/* 网卡带宽吞吐 — 双线 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>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ═══ 进程监控 — v2.0 新增 ═══ */}
|
{/* ═══ 进程监控 — v2.0 新增 ═══ */}
|
||||||
@@ -505,3 +513,68 @@ function MetricChartCard({
|
|||||||
</Card>
|
</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) => {
|
testNotifyChannel: async (id: number | string) => {
|
||||||
return http.post(`/alerts/notify-channels/${id}/test`);
|
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 ====================
|
// ==================== 流水线 API ====================
|
||||||
|
|||||||
Reference in new issue
Block a user