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:
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> || {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user