- 拆分 DeploymentView / DeploymentDetail 至子目录(<700 行红线) - AlertCenter 接入 useAlerts / useAgentAlertRules / useAlertMutations - 新增 utils/permission.ts,usePermission 复用纯函数 - useAlerts 增强分页统计与 Agent 规则 hooks
595 lines
28 KiB
TypeScript
595 lines
28 KiB
TypeScript
// 告警中心主组件 — TanStack Query 编排 // Logan.Li
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
AlertTriangle, CheckCircle, Search, RefreshCw,
|
|
Eye, Check, VolumeX, Plus, Rocket,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
import { StatStrip } from '@/components/ui/stat-strip';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select';
|
|
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, alertApi } from '@/api';
|
|
import { useAlerts, useAgentAlertRules, useAlertMutations } from '@/hooks/api';
|
|
import {
|
|
type Alert, type AlertRule, getSeverityBadge, getStatusBadge,
|
|
getChannelTypeLabel,
|
|
} from './alert-center-helpers';
|
|
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
|
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
|
|
import { ContactDialog } from './ContactDialog';
|
|
import { ContactGroupDialog } from './ContactGroupDialog';
|
|
import SilenceRuleTab from './SilenceRuleTab';
|
|
import type { Contact, ContactGroup } from './alert-center-helpers';
|
|
|
|
const AlertCenter: React.FC = () => {
|
|
const _confirm = useConfirm();
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [severityFilter, setSeverityFilter] = useState('all');
|
|
const [statusFilter, setStatusFilter] = useState('all');
|
|
const [activeTab, setActiveTab] = useState('alerts');
|
|
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [silenceOpen, setSilenceOpen] = useState(false);
|
|
// 规则弹框
|
|
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
|
const [editingRule, setEditingRule] = useState<any>(null);
|
|
// 联系人 & 联系组
|
|
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);
|
|
|
|
const alertParams = useMemo(() => ({
|
|
page,
|
|
page_size: pageSize,
|
|
severity: severityFilter,
|
|
status: statusFilter,
|
|
}), [page, pageSize, severityFilter, statusFilter]);
|
|
|
|
const { data: alertData, isLoading: loading, refetch: refetchAlerts } = useAlerts(alertParams);
|
|
const { data: rules = [], refetch: refetchRules } = useAgentAlertRules();
|
|
const {
|
|
ackMutation,
|
|
resolveMutation,
|
|
quickSetupMutation,
|
|
toggleAgentRuleMutation,
|
|
deleteAgentRuleMutation,
|
|
} = useAlertMutations();
|
|
|
|
const alerts = alertData?.list ?? [];
|
|
const total = alertData?.total ?? 0;
|
|
const stats = alertData?.stats ?? { firing: 0, critical: 0, warning: 0, resolved: 0 };
|
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'contacts') fetchContacts();
|
|
if (activeTab === 'groups') { fetchContacts(); fetchContactGroups(); }
|
|
}, [activeTab]);
|
|
|
|
const fetchContacts = async () => {
|
|
try {
|
|
const res: Record<string, any> = await alertApi.getContacts();
|
|
setContacts((res?.data?.list || res?.list || []) as Contact[]);
|
|
} catch { /* silent */ }
|
|
};
|
|
|
|
const fetchContactGroups = async () => {
|
|
try {
|
|
const res: Record<string, any> = await alertApi.getContactGroups();
|
|
setContactGroups((res?.data?.list || res?.list || []) as ContactGroup[]);
|
|
} catch { /* silent */ }
|
|
};
|
|
|
|
const handleAcknowledge = async (id: string) => {
|
|
try {
|
|
await ackMutation.mutateAsync(id);
|
|
toast.success('告警已确认');
|
|
} catch {
|
|
toast.error('操作失败');
|
|
}
|
|
};
|
|
|
|
const handleResolve = async (id: string) => {
|
|
try {
|
|
await resolveMutation.mutateAsync(id);
|
|
toast.success('告警已解决');
|
|
} catch {
|
|
toast.error('操作失败');
|
|
}
|
|
};
|
|
|
|
const handleSilence = async (id: string, duration: string) => {
|
|
try {
|
|
const alert = alerts.find(a => a.id === id);
|
|
const now = new Date();
|
|
const match = duration.match(/^(\d+)([hd])$/);
|
|
const num = match ? parseInt(match[1]) : 1;
|
|
const unit = match?.[2] || 'h';
|
|
const hours = unit === 'd' ? num * 24 : num;
|
|
const endTime = new Date(now.getTime() + hours * 3600_000);
|
|
await alertCenterApi.createSilence({
|
|
name: `静默: ${alert?.title || id} (${duration})`,
|
|
description: `从告警列表快速静默 — ${alert?.source || ''}`,
|
|
silence_type: 'onetime',
|
|
start_time: now.toISOString(),
|
|
end_time: endTime.toISOString(),
|
|
});
|
|
toast.success(`告警已静默 ${duration}`);
|
|
setSilenceOpen(false);
|
|
refetchAlerts();
|
|
} catch {
|
|
toast.error('操作失败');
|
|
}
|
|
};
|
|
|
|
const toggleRule = async (ruleId: string, enabled: boolean) => {
|
|
try {
|
|
await toggleAgentRuleMutation.mutateAsync({ id: Number(ruleId), enabled });
|
|
toast.success('规则状态已更新');
|
|
} catch {
|
|
toast.error('更新失败');
|
|
}
|
|
};
|
|
|
|
const handleOpenAddRule = () => { setEditingRule(null); setRuleDialogOpen(true); };
|
|
const handleEditRule = (rule: AlertRule) => { setEditingRule(rule); setRuleDialogOpen(true); };
|
|
const handleRuleDialogClose = () => { setRuleDialogOpen(false); setEditingRule(null); };
|
|
|
|
const handleQuickSetup = async () => {
|
|
const ok = await _confirm({
|
|
title: '一键报警',
|
|
description: '为 Agent 在线主机创建 CPU(>80%)/内存(>80%)/磁盘(>90%)/TIME_WAIT(>15000)/CLOSE_WAIT(>200)/进程检测 6 条告警规则。',
|
|
confirmText: '确认创建',
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
const res = await quickSetupMutation.mutateAsync('ecs');
|
|
toast.success(typeof res === 'string' ? res : ((res as { message?: string })?.message || '默认告警规则创建成功'));
|
|
} catch {
|
|
toast.error('创建失败,请检查 Agent 主机是否在线');
|
|
}
|
|
};
|
|
|
|
const handleDeleteRule = async (rule: AlertRule) => {
|
|
const ok = await _confirm({ title: '删除规则', description: `确定删除规则「${rule.name}」?`, confirmText: '删除', variant: 'destructive' });
|
|
if (!ok) return;
|
|
try { await deleteAgentRuleMutation.mutateAsync(Number(rule.id)); toast.success('已删除'); } catch { toast.error('删除失败'); }
|
|
};
|
|
|
|
const handleFilterChange = (type: 'severity' | 'status', value: string) => {
|
|
if (type === 'severity') setSeverityFilter(value);
|
|
else setStatusFilter(value);
|
|
setPage(1); // 过滤条件变化时重置到第一页
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 统计 — 紧凑条 */}
|
|
<StatStrip
|
|
items={[
|
|
{ label: '触发中', value: stats.firing, color: stats.firing > 0 ? 'red' : 'muted' },
|
|
{ label: '严重', value: stats.critical, color: 'red' },
|
|
{ label: '警告', value: stats.warning, color: 'amber' },
|
|
{ label: '已解决', value: stats.resolved, color: 'emerald' },
|
|
]}
|
|
/>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<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="silences" className="text-xs">静默规则</TabsTrigger>
|
|
<TabsTrigger value="contacts" className="text-xs">报警联系人</TabsTrigger>
|
|
<TabsTrigger value="groups" className="text-xs">报警联系组</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 告警列表 Tab */}
|
|
<TabsContent value="alerts" className="space-y-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 flex-1 min-w-[280px]">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
|
<Input
|
|
placeholder="搜索告警..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-8 h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<Select value={severityFilter} onValueChange={(v) => handleFilterChange('severity', v)}>
|
|
<SelectTrigger className="w-[100px] h-8 text-xs">
|
|
<SelectValue placeholder="级别" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">全部级别</SelectItem>
|
|
<SelectItem value="critical">严重</SelectItem>
|
|
<SelectItem value="warning">警告</SelectItem>
|
|
<SelectItem value="info">信息</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={statusFilter} onValueChange={(v) => handleFilterChange('status', v)}>
|
|
<SelectTrigger className="w-[100px] h-8 text-xs">
|
|
<SelectValue placeholder="状态" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">全部状态</SelectItem>
|
|
<SelectItem value="firing">触发中</SelectItem>
|
|
<SelectItem value="acknowledged">已确认</SelectItem>
|
|
<SelectItem value="resolved">已解决</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<span>共 {total} 条</span>
|
|
<Button variant="outline" size="sm" onClick={() => refetchAlerts()} className="h-7 text-xs px-2">
|
|
<RefreshCw className="w-3 h-3 mr-1" />刷新
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 告警表格 — 紧凑 */}
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted/50">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left text-xs font-medium">告警信息</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium w-16">级别</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium w-16">状态</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium w-24">来源</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium w-20">持续</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium w-28">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{loading ? (
|
|
<tr><td colSpan={6} className="px-3 py-6 text-center text-xs text-muted-foreground">加载中...</td></tr>
|
|
) : alerts.length === 0 ? (
|
|
<tr><td colSpan={6} className="px-3 py-6 text-center text-xs text-muted-foreground">暂无告警</td></tr>
|
|
) : (
|
|
alerts.filter(alert => {
|
|
// 客户端也保留关键词搜索过滤
|
|
if (!searchQuery) return true;
|
|
const q = searchQuery.toLowerCase();
|
|
return alert.title.toLowerCase().includes(q) ||
|
|
alert.description.toLowerCase().includes(q) ||
|
|
alert.source.toLowerCase().includes(q);
|
|
}).map((alert) => (
|
|
<tr key={alert.id} className="hover:bg-muted/50">
|
|
<td className="px-3 py-2">
|
|
<div className="font-medium text-xs">{alert.title}</div>
|
|
<div className="text-xs text-muted-foreground line-clamp-1">{alert.description}</div>
|
|
<div className="flex gap-1 mt-0.5">
|
|
{Object.entries(alert.labels).slice(0, 2).map(([k, v]) => (
|
|
<Badge key={k} variant="outline" className="text-[10px] px-1 py-0">{k}={v}</Badge>
|
|
))}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2">{getSeverityBadge(alert.severity)}</td>
|
|
<td className="px-3 py-2">{getStatusBadge(alert.status)}</td>
|
|
<td className="px-3 py-2 text-xs">
|
|
<div>{alert.source}</div>
|
|
<div className="text-muted-foreground text-[10px]">{alert.service}</div>
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-muted-foreground">
|
|
{alert.status === 'resolved' && alert.resolvedAt
|
|
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分`
|
|
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分`}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center gap-0.5">
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
|
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
</Button>
|
|
{alert.status === 'firing' && (
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
|
onClick={() => handleAcknowledge(alert.id)}>
|
|
<Check className="w-3.5 h-3.5 text-blue-600" />
|
|
</Button>
|
|
)}
|
|
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
|
onClick={() => handleResolve(alert.id)}>
|
|
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
|
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}>
|
|
<VolumeX className="w-3.5 h-3.5 text-orange-600" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/* 分页控件 */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between px-3 py-2 border-t text-xs">
|
|
<span className="text-muted-foreground">第 {page}/{totalPages} 页,共 {total} 条</span>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
|
|
disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
|
|
上一页
|
|
</Button>
|
|
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
|
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
|
|
const pn = start + i;
|
|
if (pn > totalPages) return null;
|
|
return (
|
|
<Button key={pn} variant={pn === page ? 'default' : 'outline'} size="sm"
|
|
className="h-7 min-w-[28px] text-xs px-1"
|
|
onClick={() => setPage(pn)}>
|
|
{pn}
|
|
</Button>
|
|
);
|
|
})}
|
|
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
|
|
disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
|
|
下一页
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 告警规则 Tab */}
|
|
<TabsContent value="rules" className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-2">
|
|
<Button onClick={handleOpenAddRule}>
|
|
<Plus className="w-4 h-4 mr-1" />添加规则
|
|
</Button>
|
|
<Button variant="secondary" onClick={handleQuickSetup} disabled={quickSetupMutation.isPending}>
|
|
<Rocket className="w-4 h-4 mr-1" />
|
|
{quickSetupMutation.isPending ? '创建中…' : '一键报警'}
|
|
</Button>
|
|
</div>
|
|
<Button variant="outline" onClick={() => refetchRules()}>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
刷新
|
|
</Button>
|
|
</div>
|
|
{rules.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<AlertTriangle className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
|
<p className="text-sm">暂无告警规则</p>
|
|
<p className="text-xs mt-1">点击「添加规则」或「一键报警」快速创建</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4">
|
|
{rules.map((rule) => (
|
|
<Card key={rule.id}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="font-medium">{rule.name}</span>
|
|
{getSeverityBadge(rule.severity)}
|
|
<Badge variant={rule.enabled ? 'default' : 'secondary'}>
|
|
{rule.enabled ? '已启用' : '已禁用'}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-2">{rule.description}</p>
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
{rule.expression && (
|
|
<span className="font-mono bg-muted px-2 py-1 rounded">{rule.expression}</span>
|
|
)}
|
|
{rule.duration ? <span>持续 {rule.duration}秒</span> : null}
|
|
</div>
|
|
{(() => {
|
|
let chs: string[] = [];
|
|
try {
|
|
const raw = rule.notify_channels;
|
|
if (raw) chs = Array.isArray(raw) ? raw : JSON.parse(raw);
|
|
} catch { /* ignore */ }
|
|
if (!chs.length) return null;
|
|
return (
|
|
<div className="flex gap-1 mt-2">
|
|
{chs.map(ch => (
|
|
<Badge key={ch} variant="outline" className="text-xs">{ch}</Badge>
|
|
))}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => handleEditRule(rule)}>编辑</Button>
|
|
<Button variant="ghost" size="sm" className="text-red-400" onClick={() => handleDeleteRule(rule)}>删除</Button>
|
|
<Switch
|
|
checked={rule.enabled}
|
|
onCheckedChange={(checked) => toggleRule(String(rule.id), checked)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 静默规则 Tab */}
|
|
<TabsContent value="silences">
|
|
<SilenceRuleTab />
|
|
</TabsContent>
|
|
|
|
{/* 报警联系人 Tab */}
|
|
<TabsContent value="contacts" className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-sm font-medium">报警联系人</h3>
|
|
<Button size="sm" onClick={() => { setEditingContact(null); setContactDialogOpen(true); }}>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
添加联系人
|
|
</Button>
|
|
</div>
|
|
{contacts.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground border border-dashed rounded-lg text-xs">
|
|
暂无报警联系人,点击「添加联系人」创建
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{contacts.map((c) => (
|
|
<Card key={c.id} className="hover:border-primary/50 transition-colors">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-start justify-between">
|
|
<CardTitle className="text-sm">{c.name}</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0 space-y-1 text-xs text-muted-foreground">
|
|
{c.phone && <div>📱 {c.phone}</div>}
|
|
{c.email && <div>📧 {c.email}</div>}
|
|
<div className="flex gap-1 flex-wrap">
|
|
{c.channel_type && (
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{getChannelTypeLabel(c.channel_type)}
|
|
</Badge>
|
|
)}
|
|
{c.webhook_url && (
|
|
<span className="text-[10px] text-muted-foreground truncate max-w-[200px]" title={c.webhook_url}>
|
|
{c.webhook_url}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-1.5 pt-1.5">
|
|
<Button variant="outline" size="sm" className="h-7 text-xs flex-1"
|
|
onClick={() => { setEditingContact(c); setContactDialogOpen(true); }}>
|
|
编辑
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-7 text-xs"
|
|
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
|
|
alert={selectedAlert}
|
|
open={detailOpen}
|
|
onOpenChange={setDetailOpen}
|
|
onAcknowledge={handleAcknowledge}
|
|
/>
|
|
<AlertSilenceDialog
|
|
alert={selectedAlert}
|
|
open={silenceOpen}
|
|
onOpenChange={setSilenceOpen}
|
|
onSilence={handleSilence}
|
|
/>
|
|
<AlertRuleDialog
|
|
open={ruleDialogOpen}
|
|
onOpenChange={handleRuleDialogClose}
|
|
editingRule={editingRule}
|
|
notifyChannels={[]}
|
|
onSaved={() => { handleRuleDialogClose(); refetchRules(); }}
|
|
/>
|
|
<ContactDialog
|
|
open={contactDialogOpen}
|
|
onOpenChange={setContactDialogOpen}
|
|
editingContact={editingContact}
|
|
onSaved={fetchContacts}
|
|
/>
|
|
<ContactGroupDialog
|
|
open={groupDialogOpen}
|
|
onOpenChange={setGroupDialogOpen}
|
|
editingGroup={editingGroup}
|
|
contacts={contacts}
|
|
onSaved={() => { fetchContactGroups(); }}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AlertCenter;
|