feat: 监控告警体系统一重构 + 自动化任务调度引擎 + 监控大屏拆分

告警中心
- 新增统一告警(alert_unified)、监控告警规则(monitor_alert_rule)、指标目录(alert_metric_catalog)
- 通知策略与消息分发(alert_notify_policy/dispatch/message)、静默规则
- 告警引擎按阈值/SNMP 拆分(alert_engine_monitor/snmp)

监控采集
- SNMP 常量/模板与历史指标(snmp templates/constants、snmp_metrics_history、snmp_collect)
- 中间件监控 Redis 采集与种子(middleware_monitor_redis/seed)
- 站点探测 pkg(siteprobe) + 站点视图(site_view)

自动化任务
- cron 调度器(cronscheduler)、脚本运行器(scriptrunner)、系统任务(systask)及运行记录(cron_job_run、system_task)

审计与凭证
- 审计设置(audit_setting) + 历史清理(auditcleaner)

前端
- 监控页面(host/middleware/site/snmp)拆分为子模块并大屏化
- 告警中心多 Tab(规则/指标目录/通知策略/静默)、任务管理组件重构、Agent CLI Markdown 渲染
- 移除旧脚本卡片/详情/日志组件,统一为 ScriptTable/ScriptCodeEditor/TerminalOutput

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
LoganandClaude committed 2026-07-01 23:44:45 +08:00
1 parent 092d0e4b9e
commit 471353e3d7
190 files changed
+16640 -3484

No files matched your search

@@ -1,8 +1,9 @@
// 告警中心主组件 — TanStack Query 编排 // Logan.Li
import React, { useState, useEffect, useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
AlertTriangle, CheckCircle, Search, RefreshCw,
Eye, Check, VolumeX, Plus, Rocket,
Eye, Check, VolumeX, Plus,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -12,23 +13,24 @@ import { StatStrip } from '@/components/ui/stat-strip';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { PageLoading } from '@/components/layout/PageLoading';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { alertCenterApi, alertApi } from '@/api';
import { useAlerts, useAgentAlertRules, useAlertMutations } from '@/hooks/api';
import { useAlerts, useUnifiedAlertRules, useMetricCatalog, useAlertMutations } from '@/hooks/api';
import {
type Alert, type AlertRule, getSeverityBadge, getStatusBadge,
getChannelTypeLabel,
type Alert, getSeverityBadge, getStatusBadge, getChannelTypeLabel,
} from './alert-center-helpers';
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
import { MetricCatalogTab } from './MetricCatalogTab';
import { AlertRulesTab } from './AlertRulesTab';
import { ContactDialog } from './ContactDialog';
import { ContactGroupDialog } from './ContactGroupDialog';
import SilenceRuleTab from './SilenceRuleTab';
import { NotifyPolicyTab } from './NotifyPolicyTab';
import type { UnifiedAlertRule } from '@/api';
import type { Contact, ContactGroup } from './alert-center-helpers';
import type { JsonData } from '@/types/common';
import { useTranslation } from 'react-i18next';
@@ -39,13 +41,11 @@ const AlertCenter: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [sourceFilter, setSourceFilter] = 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<JsonData>(null);
// 联系人 & 联系组
const [contacts, setContacts] = useState<Contact[]>([]);
const [contactGroups, setContactGroups] = useState<ContactGroup[]>([]);
@@ -58,20 +58,17 @@ const AlertCenter: React.FC = () => {
const [pageSize] = useState(10);
const alertParams = useMemo(() => ({
page,
page_size: pageSize,
severity: severityFilter,
status: statusFilter,
}), [page, pageSize, severityFilter, statusFilter]);
page, page_size: pageSize,
severity: severityFilter, status: statusFilter, source_type: sourceFilter,
}), [page, pageSize, severityFilter, statusFilter, sourceFilter]);
const { data: alertData, isLoading: loading, refetch: refetchAlerts } = useAlerts(alertParams);
const { data: rules = [], refetch: refetchRules } = useAgentAlertRules();
const { data: alertData, isLoading: loading, isFetching: fetchingAlerts, refetch: refetchAlerts } = useAlerts(alertParams);
const { data: rules = [], isFetching: fetchingRules, refetch: refetchRules } = useUnifiedAlertRules('all');
const { data: catalog = [] } = useMetricCatalog();
const {
ackMutation,
resolveMutation,
quickSetupMutation,
toggleAgentRuleMutation,
deleteAgentRuleMutation,
ackMutation, resolveMutation, quickSetupMutation, monitorQuickSetupMutation,
toggleAgentRuleMutation, deleteAgentRuleMutation,
deleteMonitorRuleMutation, toggleMonitorRuleMutation,
} = useAlertMutations();
const alerts = alertData?.list ?? [];
@@ -86,15 +83,15 @@ const AlertCenter: React.FC = () => {
const fetchContacts = async () => {
try {
const res: JsonData = await alertApi.getContacts();
setContacts((res?.data?.list || res?.list || []) as Contact[]);
const res = await alertApi.getContacts() as { list?: Contact[] };
setContacts(res?.list || []);
} catch { /* silent */ }
};
const fetchContactGroups = async () => {
try {
const res: JsonData = await alertApi.getContactGroups();
setContactGroups((res?.data?.list || res?.list || []) as ContactGroup[]);
const res = await alertApi.getContactGroups() as { list?: ContactGroup[] };
setContactGroups(res?.list || []);
} catch { /* silent */ }
};
@@ -140,19 +137,6 @@ const AlertCenter: React.FC = () => {
}
};
const toggleRule = async (ruleId: string, enabled: boolean) => {
try {
await toggleAgentRuleMutation.mutateAsync({ id: Number(ruleId), enabled });
toast.success(t('alert.ruleUpdated'));
} catch {
toast.error(t('alert.updateFailed'));
}
};
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: t('alert.quickSetup'),
@@ -168,24 +152,32 @@ const AlertCenter: React.FC = () => {
}
};
const handleDeleteRule = async (rule: AlertRule) => {
const handleDeleteAgentRule = async (rule: UnifiedAlertRule) => {
const ok = await _confirm({ title: t('alert.deleteRule'), description: `${rule.name}`, confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try { await deleteAgentRuleMutation.mutateAsync(Number(rule.id)); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
try { await deleteAgentRuleMutation.mutateAsync(rule.id); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
};
const handleFilterChange = (type: 'severity' | 'status', value: string) => {
const handleDeleteMonitorRule = async (rule: UnifiedAlertRule) => {
const ok = await _confirm({ title: t('alert.deleteRule'), description: `${rule.name}`, confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try { await deleteMonitorRuleMutation.mutateAsync(rule.id); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
};
const handleFilterChange = (type: 'severity' | 'status' | 'source', value: string) => {
if (type === 'severity') setSeverityFilter(value);
else setStatusFilter(value);
setPage(1); // 过滤条件变化时重置到第一页
else if (type === 'status') setStatusFilter(value);
else setSourceFilter(value);
setPage(1);
};
const pageTabs = [
{ id: 'alerts', label: t('alert.tab.alerts') },
{ id: 'rules', label: t('alert.tab.rules') },
{ id: 'silences', label: t('alert.tab.silences') },
{ id: 'metrics', label: '指标目录' },
{ id: 'contacts', label: t('alert.tab.contacts') },
{ id: 'groups', label: t('alert.tab.groups') },
{ id: 'notify', label: '通知策略' },
];
const alertsToolbar = activeTab === 'alerts' ? (
@@ -222,11 +214,24 @@ const AlertCenter: React.FC = () => {
<SelectItem value="resolved">{t('alert.status.resolved')}</SelectItem>
</SelectContent>
</Select>
<Select value={sourceFilter} onValueChange={(v) => handleFilterChange('source', v)}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
<SelectItem value="host">主机监控</SelectItem>
<SelectItem value="middleware">中间件监控</SelectItem>
<SelectItem value="snmp">SNMP 监控</SelectItem>
<SelectItem value="site">站点拨测</SelectItem>
<SelectItem value="log">日志告警</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1 text-xs dg-text-muted">
<span>{t('alert.totalItems', { count: total })}</span>
<Button variant="outline" size="sm" onClick={() => refetchAlerts()} className="h-7 text-xs px-2 dg-btn-secondary">
<RefreshCw className="w-3 h-3 mr-1" />{t('common.refresh')}
<Button variant="outline" size="sm" onClick={() => refetchAlerts()} disabled={fetchingAlerts} className="h-7 text-xs px-2 dg-btn-secondary">
<RefreshCw className={cn('w-3 h-3 mr-1', fetchingAlerts && 'animate-spin')} />{t('common.refresh')}
</Button>
</div>
</div>
@@ -253,18 +258,9 @@ const AlertCenter: React.FC = () => {
/>
)}
actions={activeTab === 'rules' ? (
<>
<Button className="dg-btn-primary" onClick={handleOpenAddRule}>
<Plus className="w-4 h-4 mr-1" />{t('alert.addRule')}
</Button>
<Button variant="secondary" className="dg-btn-secondary" onClick={handleQuickSetup} disabled={quickSetupMutation.isPending}>
<Rocket className="w-4 h-4 mr-1" />
{quickSetupMutation.isPending ? t('alert.quickSetupCreating') : t('alert.quickSetup')}
</Button>
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchRules()}>
<RefreshCw className="w-4 h-4 mr-2" />{t('common.refresh')}
</Button>
</>
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchRules()} disabled={fetchingRules}>
<RefreshCw className={cn('w-4 h-4 mr-2', fetchingRules && 'animate-spin')} />{t('common.refresh')}
</Button>
) : activeTab === 'contacts' ? (
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditingContact(null); setContactDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />添加联系人
@@ -393,66 +389,22 @@ const AlertCenter: React.FC = () => {
)}
{activeTab === 'rules' && (
rules.length === 0 ? (
<PageEmpty
icon={<AlertTriangle className="w-10 h-10" />}
title={t('alert.noRules')}
description={t('alert.noRulesHint')}
/>
) : (
<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 ? t('alert.enabled') : t('alert.disabled')}
</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>{t('alert.durationSeconds', { count: 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)}>{t('common.edit')}</Button>
<Button variant="ghost" size="sm" className="dg-text-red" onClick={() => handleDeleteRule(rule)}>{t('common.delete')}</Button>
<Switch
checked={rule.enabled}
onCheckedChange={(checked) => toggleRule(String(rule.id), checked)}
/>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
<AlertRulesTab
rules={rules}
loading={fetchingRules}
catalog={catalog}
onRefresh={() => refetchRules()}
onToggleAgent={(id, enabled) => toggleAgentRuleMutation.mutate({ id, enabled })}
onDeleteAgent={handleDeleteAgentRule}
onDeleteMonitor={handleDeleteMonitorRule}
onToggleMonitor={(id, enabled) => toggleMonitorRuleMutation.mutate({ id, enabled })}
onQuickSetupHost={handleQuickSetup}
onQuickSetupMonitor={(cat) => monitorQuickSetupMutation.mutate(cat)}
quickSetupPending={quickSetupMutation.isPending || monitorQuickSetupMutation.isPending}
/>
)}
{activeTab === 'silences' && <SilenceRuleTab />}
{activeTab === 'metrics' && <MetricCatalogTab />}
{activeTab === 'contacts' && (
contacts.length === 0 ? (
@@ -503,6 +455,8 @@ const AlertCenter: React.FC = () => {
)
)}
{activeTab === 'notify' && <NotifyPolicyTab />}
{activeTab === 'groups' && (
contactGroups.length === 0 ? (
<PageEmpty title="暂无报警联系组" description="点击「添加联系组」创建" />
@@ -564,13 +518,6 @@ const AlertCenter: React.FC = () => {
onOpenChange={setSilenceOpen}
onSilence={handleSilence}
/>
<AlertRuleDialog
open={ruleDialogOpen}
onOpenChange={handleRuleDialogClose}
editingRule={editingRule}
notifyChannels={[]}
onSaved={() => { handleRuleDialogClose(); refetchRules(); }}
/>
<ContactDialog
open={contactDialogOpen}
onOpenChange={setContactDialogOpen}
@@ -0,0 +1,188 @@
// 告警规则 Tab — 主机/中间件/SNMP/站点 四类分栏 // Logan.Li
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Plus, Rocket, Server, Database, Network, Globe } from 'lucide-react';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { PageLoading } from '@/components/layout/PageLoading';
import { getSeverityBadge } from './alert-center-helpers';
import { AgentRuleDialog } from '@/components/agent/AgentRuleDialog';
import { MonitorRuleDialog } from './MonitorRuleDialog';
import type { MetricCatalogCategory, UnifiedAlertRule } from '@/api';
import type { JsonData } from '@/types/common';
type RuleCategory = 'host' | 'middleware' | 'snmp' | 'site';
type MonitorCategory = 'middleware' | 'snmp' | 'site';
const RULE_CATEGORIES: Array<{
id: RuleCategory;
label: string;
icon: typeof Server;
desc: string;
}> = [
{ id: 'host', label: '主机监控', icon: Server, desc: 'Agent CPU/内存/磁盘/离线' },
{ id: 'middleware', label: '中间件', icon: Database, desc: 'Redis / MySQL 指标' },
{ id: 'snmp', label: 'SNMP', icon: Network, desc: '网络设备 / BMC' },
{ id: 'site', label: '站点拨测', icon: Globe, desc: 'HTTP/HTTPS 可用性与证书' },
];
interface Props {
rules: UnifiedAlertRule[];
loading: boolean;
catalog: MetricCatalogCategory[];
onRefresh: () => void;
onToggleAgent: (id: number, enabled: boolean) => void;
onDeleteAgent: (rule: UnifiedAlertRule) => void;
onDeleteMonitor: (rule: UnifiedAlertRule) => void;
onToggleMonitor: (id: number, enabled: boolean) => void;
onQuickSetupHost: () => void;
onQuickSetupMonitor: (cat: string) => void;
quickSetupPending: boolean;
}
export function AlertRulesTab({
rules, loading, catalog, onRefresh,
onToggleAgent, onDeleteAgent, onDeleteMonitor, onToggleMonitor,
onQuickSetupHost, onQuickSetupMonitor, quickSetupPending,
}: Props) {
const [activeCat, setActiveCat] = useState<RuleCategory>('host');
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
const [monitorDialogOpen, setMonitorDialogOpen] = useState(false);
const [monitorCategory, setMonitorCategory] = useState<MonitorCategory>('middleware');
const [editingAgent, setEditingAgent] = useState<JsonData>(null);
const [editingMonitor, setEditingMonitor] = useState<UnifiedAlertRule | null>(null);
const filtered = rules.filter((r) => r.category === activeCat);
const catMeta = RULE_CATEGORIES.find((c) => c.id === activeCat)!;
const getMetricsFor = (cat: MonitorCategory) =>
catalog.find((c) => c.id === cat)?.metrics || [];
const openAdd = () => {
if (activeCat === 'host') {
setEditingAgent(null);
setAgentDialogOpen(true);
return;
}
setMonitorCategory(activeCat as MonitorCategory);
setEditingMonitor(null);
setMonitorDialogOpen(true);
};
const handleQuickSetup = () => {
if (activeCat === 'host') {
onQuickSetupHost();
} else {
onQuickSetupMonitor(activeCat);
}
};
if (loading) return <PageLoading label="加载规则..." />;
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{RULE_CATEGORIES.map((cat) => {
const Icon = cat.icon;
const count = rules.filter((r) => r.category === cat.id).length;
return (
<Button
key={cat.id}
variant={activeCat === cat.id ? 'default' : 'outline'}
size="sm"
className="h-8 text-xs gap-1.5"
onClick={() => setActiveCat(cat.id)}
>
<Icon className="h-3.5 w-3.5" />
{cat.label}
{count > 0 && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">{count}</Badge>
)}
</Button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-muted-foreground flex-1 min-w-[200px]">{catMeta.desc}</p>
<Button size="sm" className="h-8" onClick={openAdd}>
<Plus className="h-3.5 w-3.5 mr-1" /> 添加规则
</Button>
<Button size="sm" variant="secondary" className="h-8" onClick={handleQuickSetup} disabled={quickSetupPending}>
<Rocket className="h-3.5 w-3.5 mr-1" /> 默认规则
</Button>
</div>
{filtered.length === 0 ? (
<PageEmpty title={`暂无${catMeta.label}规则`} description="点击「添加规则」或「默认规则」创建" />
) : (
<div className="grid grid-cols-1 gap-3">
{filtered.map((rule) => (
<Card key={`${rule.rule_type}-${rule.id}`}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="font-medium text-sm">{rule.name}</span>
{getSeverityBadge(rule.severity)}
<Badge variant={rule.enabled ? 'default' : 'secondary'} className="text-[10px]">
{rule.enabled ? '启用' : '停用'}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono">
{rule.metric} {rule.operator} {rule.threshold}
{rule.target ? ` · ${rule.target}` : ''}
</p>
{rule.description && (
<p className="text-[10px] text-muted-foreground mt-0.5">{rule.description}</p>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button variant="ghost" size="sm" className="h-7 text-xs"
onClick={() => {
if (rule.rule_type === 'agent') {
setEditingAgent(rule as unknown as JsonData);
setAgentDialogOpen(true);
} else {
setEditingMonitor(rule);
setMonitorCategory(rule.category as MonitorCategory);
setMonitorDialogOpen(true);
}
}}>编辑</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red"
onClick={() => rule.rule_type === 'agent' ? onDeleteAgent(rule) : onDeleteMonitor(rule)}>删除</Button>
<Switch checked={rule.enabled}
onCheckedChange={(v) => rule.rule_type === 'agent'
? onToggleAgent(rule.id, v)
: onToggleMonitor(rule.id, v)} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
<AgentRuleDialog
open={agentDialogOpen}
onOpenChange={setAgentDialogOpen}
editingRule={editingAgent}
onSuccess={() => { setAgentDialogOpen(false); onRefresh(); }}
/>
<MonitorRuleDialog
open={monitorDialogOpen}
onOpenChange={setMonitorDialogOpen}
category={monitorCategory}
metrics={getMetricsFor(monitorCategory)}
editing={editingMonitor ? {
id: editingMonitor.id, name: editingMonitor.name, metric: editingMonitor.metric,
operator: editingMonitor.operator, threshold: editingMonitor.threshold,
severity: editingMonitor.severity, enabled: editingMonitor.enabled,
} : null}
onSaved={onRefresh}
/>
</div>
);
}
@@ -0,0 +1,129 @@
// 监控指标目录 — 主机/中间件/站点拨测可采集指标 // Logan.Li
import { useEffect, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Loader2, RefreshCw, Server, Database, Globe, Network } from 'lucide-react';
import { alertMetricsApi, type MetricCatalogCategory } from '@/api';
const CATEGORY_ORDER = ['host', 'middleware', 'snmp', 'site'];
const categoryIcons: Record<string, typeof Server> = {
host: Server,
middleware: Database,
snmp: Network,
site: Globe,
};
export function MetricCatalogTab() {
const [categories, setCategories] = useState<MetricCatalogCategory[]>([]);
const [loading, setLoading] = useState(true);
const [activeCat, setActiveCat] = useState<string>('host');
const load = async () => {
setLoading(true);
try {
const data = await alertMetricsApi.getCatalog();
const cats = (data as { categories?: MetricCatalogCategory[] })?.categories || [];
setCategories(cats);
if (cats.length > 0 && !cats.find(c => c.id === activeCat)) {
setActiveCat(cats[0].id);
}
} catch { /* ignore */ } finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const current = categories.find(c => c.id === activeCat);
if (loading) {
return (
<div className="flex justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
主机 / 中间件 / SNMP / 站点拨测可采集指标,含默认告警阈值参考
</p>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={load}>
<RefreshCw className="h-3 w-3 mr-1" /> 刷新
</Button>
</div>
<div className="flex flex-wrap gap-2">
{[...categories].sort((a, b) => {
const ai = CATEGORY_ORDER.indexOf(a.id);
const bi = CATEGORY_ORDER.indexOf(b.id);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
}).map(cat => {
const Icon = categoryIcons[cat.id] || Server;
return (
<Button
key={cat.id}
variant={activeCat === cat.id ? 'default' : 'outline'}
size="sm"
className="h-8 text-xs gap-1.5"
onClick={() => setActiveCat(cat.id)}
>
<Icon className="h-3.5 w-3.5" />
{cat.label}
<Badge variant="secondary" className="text-[10px] h-4 px-1">{cat.metrics.length}</Badge>
</Button>
);
})}
</div>
{current && (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-3">{current.description}</p>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="px-2 py-2 text-left font-medium">指标</th>
<th className="px-2 py-2 text-left font-medium">单位</th>
<th className="px-2 py-2 text-left font-medium">类型</th>
<th className="px-2 py-2 text-left font-medium">警告阈值</th>
<th className="px-2 py-2 text-left font-medium">严重阈值</th>
<th className="px-2 py-2 text-left font-medium">上限</th>
<th className="px-2 py-2 text-left font-medium">说明</th>
</tr>
</thead>
<tbody className="divide-y">
{current.metrics.map(m => (
<tr key={m.key} className="hover:bg-muted/30">
<td className="px-2 py-2">
<div className="font-medium">{m.label}</div>
<code className="text-[10px] text-muted-foreground">{m.key}</code>
</td>
<td className="px-2 py-2 text-muted-foreground">{m.unit || '—'}</td>
<td className="px-2 py-2">
<Badge variant="outline" className="text-[10px]">{m.type}</Badge>
</td>
<td className="px-2 py-2 dg-text-yellow">
{m.default_op} {m.default_warn}{m.unit && m.unit !== '' ? m.unit : ''}
</td>
<td className="px-2 py-2 dg-text-red">
{m.default_op} {m.default_crit}{m.unit && m.unit !== '' ? m.unit : ''}
</td>
<td className="px-2 py-2 text-muted-foreground">{m.max}</td>
<td className="px-2 py-2 text-muted-foreground max-w-[200px]">{m.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,174 @@
// 中间件/站点拨测告警规则弹框 // Logan.Li
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Loader2 } from 'lucide-react';
import { alertMetricsApi, type MetricCatalogItem } from '@/api';
import { SEVERITY_OPTIONS } from '@/components/agent/agentAlertHelper';
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
category: 'middleware' | 'snmp' | 'site';
metrics: MetricCatalogItem[];
editing?: { id: number; name: string; metric: string; operator: string; threshold: number; severity: string; enabled: boolean; target_id?: number } | null;
onSaved: () => void;
}
const OPERATORS = [
{ value: '>', label: '> 大于' },
{ value: '<', label: '< 小于' },
{ value: '>=', label: '≥ 大于等于' },
{ value: '<=', label: '≤ 小于等于' },
{ value: '==', label: '= 等于' },
];
export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editing, onSaved }: Props) {
const [form, setForm] = useState({
name: '', metric: '', operator: '>', threshold: '0', severity: 'warning', enabled: true,
});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
if (editing) {
setForm({
name: editing.name,
metric: editing.metric,
operator: editing.operator || '>',
threshold: String(editing.threshold),
severity: editing.severity || 'warning',
enabled: editing.enabled,
});
} else {
const first = metrics[0];
setForm({
name: '', metric: first?.key || '', operator: first?.default_op || '>',
threshold: String(first?.default_warn ?? 0), severity: 'warning', enabled: true,
});
}
}, [open, editing, metrics]);
const selectedMetric = metrics.find(m => m.key === form.metric);
const handleMetricChange = (key: string) => {
const m = metrics.find(x => x.key === key);
setForm(prev => ({
...prev,
metric: key,
operator: m?.default_op || prev.operator,
threshold: String(m?.default_warn ?? prev.threshold),
}));
};
const handleSave = async () => {
if (!form.name.trim() || !form.metric) {
toast.error('请填写规则名称和指标');
return;
}
setSaving(true);
try {
const payload = {
category, name: form.name.trim(), metric: form.metric,
operator: form.operator, threshold: Number(form.threshold) || 0,
severity: form.severity, enabled: form.enabled,
};
if (editing) {
await alertMetricsApi.updateMonitorRule(editing.id, payload);
toast.success('规则已更新');
} else {
await alertMetricsApi.createMonitorRule(payload);
toast.success('规则已创建');
}
onOpenChange(false);
onSaved();
} catch (e) {
toast.error((e as { message?: string }).message || '保存失败');
} finally {
setSaving(false);
}
};
const catLabels: Record<string, string> = {
middleware: '中间件', snmp: 'SNMP', site: '站点拨测',
};
const catLabel = catLabels[category] || category;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle>{editing ? '编辑' : '新建'}{catLabel}告警规则</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
基于监控中心采集指标配置阈值告警
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1.5">
<Label>规则名称</Label>
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="例如:Redis 连接数过高" />
</div>
<div className="grid gap-1.5">
<Label>监控指标</Label>
<Select value={form.metric} onValueChange={handleMetricChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{metrics.map(m => (
<SelectItem key={m.key} value={m.key}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
{selectedMetric && (
<p className="text-[10px] text-muted-foreground">{selectedMetric.description}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>运算符</Label>
<Select value={form.operator} onValueChange={v => setForm({ ...form, operator: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{OPERATORS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>阈值{selectedMetric?.unit ? ` (${selectedMetric.unit})` : ''}</Label>
<Input type="number" value={form.threshold}
min={selectedMetric?.min} max={selectedMetric?.max}
onChange={e => setForm({ ...form, threshold: e.target.value })} />
</div>
</div>
<div className="grid gap-1.5">
<Label>严重程度</Label>
<Select value={form.severity} onValueChange={v => setForm({ ...form, severity: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SEVERITY_OPTIONS.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.enabled} onCheckedChange={v => setForm({ ...form, enabled: v })} />
<Label>启用规则</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>取消</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />} 保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,403 @@
// 告警集合通知策略 — 四类监控绑定联系组 + 消息模版 // Logan.Li
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Server, Database, Globe, Network, Bell, Zap, Loader2, Save, FileText } from 'lucide-react';
import { SilenceRulesPanel } from './SilenceRulesPanel';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { PageLoading } from '@/components/layout/PageLoading';
import { alertApi } from '@/api';
import type { Contact, ContactGroup } from './alert-center-helpers';
export interface NotifyPolicy {
id: number;
category: 'host' | 'middleware' | 'site' | 'snmp';
label?: string;
enabled: boolean;
contact_group_ids?: string;
contact_ids?: string;
notify_mode?: string;
description?: string;
}
import type { NotifyMessageSettings } from '@/api/monitor/alert';
type PolicyDraft = { enabled: boolean; groupIds: number[]; contactIds: number[] };
const CATEGORIES = [
{ id: 'host' as const, label: '主机监控', desc: 'Agent 采集 CPU/内存/磁盘/离线等', icon: Server },
{ id: 'middleware' as const, label: '中间件监控', desc: 'Redis/MySQL 等指标告警', icon: Database },
{ id: 'site' as const, label: '站点拨测', desc: 'HTTP/TCP/DNS 拨测失败与延迟', icon: Globe },
{ id: 'snmp' as const, label: 'SNMP 监控', desc: 'H3C/华为/Cisco/锐捷/Dell/浪潮', icon: Network },
];
const DEFAULT_TITLE = '[{{severity_label}}] {{rule_name}} — {{hostname}} ({{metric}})';
const DEFAULT_FOOTER = '{{time}} | {{platform}}';
const DEFAULT_PLATFORM = 'AIOps 智能运维平台';
const TEMPLATE_VARS = [
'{{time}}', '{{platform}}', '{{rule_name}}', '{{hostname}}',
'{{metric}}', '{{severity}}', '{{severity_label}}',
];
function parseIDs(raw?: string): number[] {
if (!raw || raw === 'null') return [];
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.map(Number).filter(n => !isNaN(n)) : [];
} catch {
return [];
}
}
function draftKey(d: PolicyDraft) {
return `${d.enabled}|${d.groupIds.join(',')}|${d.contactIds.join(',')}`;
}
export function NotifyPolicyTab() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const [testCategory, setTestCategory] = useState('host');
const [contacts, setContacts] = useState<Contact[]>([]);
const [groups, setGroups] = useState<ContactGroup[]>([]);
const [drafts, setDrafts] = useState<Record<string, PolicyDraft>>({});
const [savedDraftKeys, setSavedDraftKeys] = useState<Record<string, string>>({});
const [msgSettings, setMsgSettings] = useState<NotifyMessageSettings>({
title_template: DEFAULT_TITLE,
footer_template: DEFAULT_FOOTER,
platform_name: DEFAULT_PLATFORM,
});
const [savedMsgSettings, setSavedMsgSettings] = useState<NotifyMessageSettings>({
title_template: DEFAULT_TITLE,
footer_template: DEFAULT_FOOTER,
platform_name: DEFAULT_PLATFORM,
});
const load = useCallback(async () => {
setLoading(true);
try {
const [policyRes, contactRes, groupRes, msgRes] = await Promise.all([
alertApi.getNotifyPolicies(),
alertApi.getContacts(),
alertApi.getContactGroups(),
alertApi.getNotifyMessageSettings(),
]);
const list = (policyRes as { list?: NotifyPolicy[] })?.list || [];
setContacts((contactRes as { list?: Contact[] })?.list || []);
setGroups((groupRes as { list?: ContactGroup[] })?.list || []);
const next: Record<string, PolicyDraft> = {};
const keys: Record<string, string> = {};
for (const p of list) {
const d = {
enabled: p.enabled,
groupIds: parseIDs(p.contact_group_ids),
contactIds: parseIDs(p.contact_ids),
};
next[p.category] = d;
keys[p.category] = draftKey(d);
}
for (const c of CATEGORIES) {
if (!next[c.id]) {
const d = { enabled: false, groupIds: [], contactIds: [] };
next[c.id] = d;
keys[c.id] = draftKey(d);
}
}
setDrafts(next);
setSavedDraftKeys(keys);
const msg = (msgRes || {}) as NotifyMessageSettings;
const merged = {
title_template: msg.title_template || DEFAULT_TITLE,
footer_template: msg.footer_template || DEFAULT_FOOTER,
platform_name: msg.platform_name || DEFAULT_PLATFORM,
};
setMsgSettings(merged);
setSavedMsgSettings(merged);
} catch {
toast.error('加载通知策略失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const policiesDirty = useMemo(() =>
CATEGORIES.some(c => draftKey(drafts[c.id] || { enabled: false, groupIds: [], contactIds: [] }) !== savedDraftKeys[c.id]),
[drafts, savedDraftKeys]);
const templateDirty = useMemo(() =>
msgSettings.title_template !== savedMsgSettings.title_template
|| msgSettings.footer_template !== savedMsgSettings.footer_template
|| msgSettings.platform_name !== savedMsgSettings.platform_name,
[msgSettings, savedMsgSettings]);
const isDirty = policiesDirty || templateDirty;
const footerPreview = useMemo(() => {
const now = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
return (msgSettings.footer_template || DEFAULT_FOOTER)
.replace('{{time}}', now)
.replace('{{platform}}', msgSettings.platform_name || DEFAULT_PLATFORM);
}, [msgSettings]);
const toggleGroup = (cat: string, gid: number) => {
setDrafts(prev => {
const d = prev[cat] || { enabled: false, groupIds: [], contactIds: [] };
const groupIds = d.groupIds.includes(gid) ? d.groupIds.filter(id => id !== gid) : [...d.groupIds, gid];
return { ...prev, [cat]: { ...d, groupIds } };
});
};
const toggleContact = (cat: string, cid: number) => {
setDrafts(prev => {
const d = prev[cat] || { enabled: false, groupIds: [], contactIds: [] };
const contactIds = d.contactIds.includes(cid) ? d.contactIds.filter(id => id !== cid) : [...d.contactIds, cid];
return { ...prev, [cat]: { ...d, contactIds } };
});
};
const handleSaveAll = async () => {
setSaving(true);
try {
await Promise.all([
...CATEGORIES.map(c => {
const d = drafts[c.id];
if (!d) return Promise.resolve();
return alertApi.upsertNotifyPolicy(c.id, {
enabled: d.enabled,
contact_group_ids: d.groupIds,
contact_ids: d.contactIds,
});
}),
alertApi.saveNotifyMessageSettings(msgSettings),
]);
toast.success('通知配置已保存');
load();
} catch (e) {
toast.error((e as { message?: string }).message || '保存失败');
} finally {
setSaving(false);
}
};
const handleTest = async (category: string) => {
setTesting(category);
try {
await alertApi.testNotifyPolicy(category);
toast.success('测试通知已发送');
} catch (e) {
toast.error((e as { message?: string }).message || '测试失败,请检查策略与联系人');
} finally {
setTesting(null);
}
};
if (loading) return <PageLoading label="加载通知策略..." />;
return (
<div className="space-y-4">
{/* 顶栏:说明 + 统一保存 / 测试 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-[var(--dg-text-secondary)] max-w-xl">
按监控类型配置默认通知对象;消息模版作用于飞书卡片标题与底部署名。
</p>
<div className="flex flex-wrap items-center gap-2">
<Select value={testCategory} onValueChange={setTestCategory}>
<SelectTrigger className="h-8 w-[7.5rem] text-xs dg-input">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input">
{CATEGORIES.map(c => (
<SelectItem key={c.id} value={c.id} className="text-xs">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
disabled={testing !== null || !drafts[testCategory]?.enabled}
onClick={() => handleTest(testCategory)}
>
{testing ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Zap className="h-3 w-3 mr-1" />}
测试
</Button>
<Button
size="sm"
className="h-8 text-xs dg-btn-primary"
disabled={saving || !isDirty}
onClick={handleSaveAll}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
保存全部
</Button>
</div>
</div>
{groups.length === 0 && contacts.length === 0 && (
<div className="text-xs text-amber-500 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2">
请先在「报警联系人」和「报警联系组」中创建通知对象。
</div>
)}
{/* 消息模版 */}
<Card className="border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4 text-[var(--dg-blue)]" />
消息通知模版
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs text-[var(--dg-text-secondary)]">标题模版</Label>
<Input
className="dg-input text-xs"
value={msgSettings.title_template}
onChange={e => setMsgSettings(s => ({ ...s, title_template: e.target.value }))}
placeholder={DEFAULT_TITLE}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-[var(--dg-text-secondary)]">平台署名</Label>
<Input
className="dg-input text-xs"
value={msgSettings.platform_name}
onChange={e => setMsgSettings(s => ({ ...s, platform_name: e.target.value }))}
placeholder={DEFAULT_PLATFORM}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-[var(--dg-text-secondary)]">卡片底部</Label>
<Input
className="dg-input text-xs"
value={msgSettings.footer_template}
onChange={e => setMsgSettings(s => ({ ...s, footer_template: e.target.value }))}
placeholder={DEFAULT_FOOTER}
/>
<p className="text-[10px] text-[var(--dg-text-muted)]">
预览:{footerPreview}
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{TEMPLATE_VARS.map(v => (
<Badge key={v} variant="outline" className="text-[10px] font-mono">{v}</Badge>
))}
</div>
</CardContent>
</Card>
{/* 四类通知策略 */}
<div className="grid gap-4 md:grid-cols-2">
{CATEGORIES.map(cat => {
const Icon = cat.icon;
const d = drafts[cat.id] || { enabled: false, groupIds: [], contactIds: [] };
const memberCount = d.groupIds.length + d.contactIds.length;
const catDirty = draftKey(d) !== savedDraftKeys[cat.id];
return (
<Card key={cat.id} className={`border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] ${catDirty ? 'ring-1 ring-[var(--dg-orange)]/40' : ''}`}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-primary/10">
<Icon className="h-4 w-4 text-primary" />
</div>
<div>
<CardTitle className="text-sm flex items-center gap-2">
{cat.label}
{catDirty && <span className="text-[10px] text-[var(--dg-orange)]">未保存</span>}
</CardTitle>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{cat.desc}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch
checked={d.enabled}
onCheckedChange={v => setDrafts(prev => ({
...prev,
[cat.id]: { ...d, enabled: v },
}))}
/>
<Label className="text-xs text-[var(--dg-text-secondary)] min-w-[2rem]">
{d.enabled ? '启用' : '停用'}
</Label>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-[var(--dg-text-secondary)]">通知联系组</Label>
{groups.length === 0 ? (
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">暂无联系组</p>
) : (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{groups.map(g => (
<Button
key={g.id}
type="button"
size="sm"
variant={d.groupIds.includes(g.id) ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={() => toggleGroup(cat.id, g.id)}
>
{g.group_name}
</Button>
))}
</div>
)}
</div>
<div>
<Label className="text-xs text-[var(--dg-text-secondary)]">额外联系人</Label>
{contacts.length === 0 ? (
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">暂无联系人</p>
) : (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{contacts.map(c => (
<Button
key={c.id}
type="button"
size="sm"
variant={d.contactIds.includes(c.id) ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={() => toggleContact(cat.id, c.id)}
>
{c.name}
</Button>
))}
</div>
)}
</div>
{memberCount > 0 && (
<Badge variant="outline" className="text-[10px]">
已选 {d.groupIds.length} 组 + {d.contactIds.length} 人
</Badge>
)}
</CardContent>
</Card>
);
})}
</div>
<div className="flex items-center gap-2 text-[10px] text-[var(--dg-text-muted)]">
<Bell className="h-3 w-3" />
未配置策略时将回退到「通知渠道」中的全局飞书渠道。
</div>
<SilenceRulesPanel />
</div>
);
}
@@ -0,0 +1,170 @@
// 告警静默规则面板 — 嵌入通知策略 Tab // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { VolumeX, Plus, RefreshCw, Clock, Calendar, Repeat, Edit, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { alertCenterApi } from '@/api';
import { SilenceDialog } from './SilenceDialog';
import type { JsonData } from '@/types/common';
const SILENCE_TYPE_LABELS: Record<string, string> = {
onetime: '一次性',
daily: '每天',
weekly: '每周',
};
const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
function formatTime(t: string | Date): string {
if (!t) return '—';
const d = typeof t === 'string' ? new Date(t) : t;
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
export function SilenceRulesPanel() {
const confirm = useConfirm();
const [silences, setSilences] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<JsonData>(null);
const fetchSilences = useCallback(async () => {
setLoading(true);
try {
const res: JsonData = await alertCenterApi.getSilences();
const list = res?.list || res?.data?.list || [];
setSilences(list);
} catch {
toast.error('获取静默规则失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchSilences(); }, [fetchSilences]);
const handleDelete = async (silence: JsonData) => {
const ok = await confirm({
title: '删除静默规则',
description: `确定删除「${silence.name}」?`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
try {
await alertCenterApi.deleteSilence(silence.id);
toast.success('已删除');
fetchSilences();
} catch {
toast.error('删除失败');
}
};
const handleToggle = async (silence: JsonData) => {
try {
await alertCenterApi.toggleSilence(silence.id);
toast.success(silence.status === 1 ? '已禁用' : '已启用');
fetchSilences();
} catch {
toast.error('操作失败');
}
};
const isCurrentlyActive = (s: JsonData): boolean => {
if (s.is_active !== undefined) return s.is_active;
if (s.status !== 1) return false;
const now = new Date();
return now >= new Date(s.start_time) && now <= new Date(s.end_time);
};
return (
<div className="space-y-3 border-t border-[var(--dg-border)] pt-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<VolumeX className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">静默规则</h3>
<span className="text-xs text-muted-foreground">维护窗口 / 免打扰时段</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={fetchSilences} disabled={loading}>
<RefreshCw className={`w-3 h-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
</Button>
<Button size="sm" className="h-7 text-xs" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-3 h-3 mr-1" />添加
</Button>
</div>
</div>
{loading && silences.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-6">加载中...</p>
) : silences.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border border-dashed border-[var(--dg-border)] rounded-lg">
<p className="text-xs">暂无静默规则,告警将按通知策略正常发送</p>
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{silences.map((s) => {
const active = isCurrentlyActive(s);
const enabled = s.status === 1;
const sType = s.silence_type || 'onetime';
const weekdayList: number[] = s.weekday_list || (() => {
try { return JSON.parse(s.weekdays || '[]'); } catch { return []; }
})();
return (
<Card key={s.id} className={active ? 'border-primary/50' : ''}>
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-xs">{s.name}</span>
<Badge variant="outline" className="text-[10px]">
{SILENCE_TYPE_LABELS[sType] || sType}
</Badge>
{active && <Badge className="bg-green-500 text-[10px]">生效中</Badge>}
{!enabled && <Badge variant="secondary" className="text-[10px]">已禁用</Badge>}
</div>
<p className="text-[10px] text-muted-foreground">
{sType === 'onetime'
? `${formatTime(s.start_time)} — ${formatTime(s.end_time)}`
: `每日 ${formatTime(s.start_time)} — ${formatTime(s.end_time)}`}
{sType === 'weekly' && weekdayList.length > 0 && (
<span className="ml-2">
周{weekdayList.sort((a, b) => a - b).map((d) => WEEKDAY_LABELS[d]).join('')}
</span>
)}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<Switch checked={enabled} onCheckedChange={() => handleToggle(s)} />
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => { setEditing(s); setDialogOpen(true); }}>
<Edit className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 dg-text-red" onClick={() => handleDelete(s)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<SilenceDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingSilence={editing}
onSaved={fetchSilences}
/>
</div>
);
}
@@ -76,27 +76,35 @@ export interface ContactGroup {
updated_at?: string;
}
const SOURCE_LABELS: Record<string, string> = {
host: '主机监控', middleware: '中间件监控', site: '站点拨测', snmp: 'SNMP 监控', log: '日志告警',
};
// Logan.Li — 后端字段映射到前端 Alert 接口
export function mapAlert(a: JsonData): Alert {
const sourceType = String(a.source_type || a.sourceType || '');
return {
id: String(a.id),
title: a.rule_name || a.title || `告警 #${a.id}`,
title: a.title || a.rule_name || `告警 #${a.id}`,
description: a.message || a.description || '',
severity: a.severity || 'info',
status: a.status || 'firing',
source: a.target || a.source || '',
service: a.service || '',
severity: (a.severity || 'info') as Alert['severity'],
status: (a.status || 'firing') as Alert['status'],
source: SOURCE_LABELS[sourceType] || a.target || a.source || '',
service: a.target || a.service || '',
metric: a.metric || '',
value: a.value || 0,
threshold: a.threshold || 0,
startedAt: a.started_at || a.startedAt || a.created_at || new Date().toISOString(),
resolvedAt: a.resolved_at || a.resolvedAt,
labels: typeof a.labels === 'string'
? (() => { try { return JSON.parse(a.labels || '{}'); } catch { return {}; } })()
: (a.labels || {}),
value: Number(a.value) || 0,
threshold: Number(a.threshold) || 0,
startedAt: String(a.started_at || a.startedAt || a.created_at || new Date().toISOString()),
resolvedAt: a.resolved_at ? String(a.resolved_at) : (a.resolvedAt ? String(a.resolvedAt) : undefined),
labels: {
...(typeof a.labels === 'string'
? (() => { try { return JSON.parse(a.labels || '{}'); } catch { return {}; } })()
: (a.labels as Record<string, string> || {})),
...(sourceType ? { source_type: sourceType } : {}),
},
annotations: typeof a.annotations === 'string'
? (() => { try { return JSON.parse(a.annotations || '{}'); } catch { return {}; } })()
: (a.annotations || {}),
: (a.annotations as Record<string, string> || {}),
};
}