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,139 +1,49 @@
|
||||
// 站点拨测 — HTTP / HTTPS / TCP / DNS / PING 主动监控,连接真实后端 API
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
// 站点拨测 — 任务列表 + 详情可观测 // Logan.Li
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Globe,
|
||||
Server,
|
||||
ArrowUpDown,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Activity,
|
||||
Plus, Trash2, Pencil, Loader2, RefreshCw, Activity, ShieldAlert, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { siteApi } from '@/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
// 拨测协议类型(与后端 model 对齐:http, https, tcp, dns, ping)
|
||||
const probeTypes = [
|
||||
{ icon: Globe, name: 'HTTP', color: 'dg-text-blue', desc: 'HTTP URL 状态码 & 响应时间' },
|
||||
{ icon: Globe, name: 'HTTPS', color: 'dg-text-green', desc: 'HTTPS SSL 证书 & 响应时间' },
|
||||
{ icon: Server, name: 'TCP', color: 'dg-text-orange', desc: 'TCP 端口连通性检测' },
|
||||
{ icon: Globe, name: 'DNS', color: 'dg-text-cyan', desc: '域名解析延迟 & 结果验证' },
|
||||
{ icon: ArrowUpDown, name: 'PING', color: 'dg-text-purple', desc: 'ICMP 连通性 & 延迟' },
|
||||
] as const;
|
||||
|
||||
const PROBE_TYPE_OPTIONS = ['HTTP', 'HTTPS', 'TCP', 'DNS', 'PING'] as const;
|
||||
type ProbeType = (typeof PROBE_TYPE_OPTIONS)[number];
|
||||
|
||||
interface SiteStats {
|
||||
total_tasks: number;
|
||||
enabled_tasks: number;
|
||||
up_count: number;
|
||||
down_count: number;
|
||||
availability: number;
|
||||
}
|
||||
|
||||
interface SiteTask {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
type: string;
|
||||
frequency_sec: number;
|
||||
timeout_sec: number;
|
||||
expected_status_code: number;
|
||||
enabled: boolean;
|
||||
last_status?: string;
|
||||
last_response_time_ms?: number;
|
||||
last_checked_at?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
import { SiteProtocolPieChart } from './site-monitor/SiteProtocolPieChart';
|
||||
import { SiteTaskDetailDialog } from './site-monitor/SiteTaskDetailDialog';
|
||||
import {
|
||||
PROBE_TYPE_OPTIONS, type ProbeType, type SiteStats, type SiteTask,
|
||||
probeTypeMeta, fmtAvailResponse, fmtFrequency, isSslProbeTask, isSslExpiringSoon, normalizeProbeType,
|
||||
} from './site-monitor/helpers';
|
||||
|
||||
interface CreateTaskForm {
|
||||
name: string;
|
||||
url: string;
|
||||
type: ProbeType;
|
||||
frequency_sec: number;
|
||||
timeout_sec: number;
|
||||
expected_status_code: number;
|
||||
name: string; url: string; type: ProbeType;
|
||||
frequency_sec: number; timeout_sec: number; expected_status_code: number;
|
||||
}
|
||||
|
||||
const emptyForm: CreateTaskForm = {
|
||||
name: '',
|
||||
url: '',
|
||||
type: 'HTTP',
|
||||
frequency_sec: 60,
|
||||
timeout_sec: 10,
|
||||
expected_status_code: 200,
|
||||
name: '', url: '', type: 'HTTP',
|
||||
frequency_sec: 60, timeout_sec: 10, expected_status_code: 200,
|
||||
};
|
||||
|
||||
function statusIcon(status?: string) {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <CheckCircle2 className="h-4 w-4 dg-text-green" />;
|
||||
case 'down':
|
||||
return <XCircle className="h-4 w-4 dg-text-red" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 dg-text-secondary" />;
|
||||
const FILTER_TYPES: Array<ProbeType | 'ALL'> = ['ALL', ...PROBE_TYPE_OPTIONS];
|
||||
|
||||
function alertStatusBadge(status?: string) {
|
||||
if (status === 'alerting') {
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30 text-[10px]">告警中</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status?: string) {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">正常</Badge>;
|
||||
case 'down':
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
default:
|
||||
return <Badge className="dg-badge-muted">未知</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtMs(ms?: number): string {
|
||||
if (ms == null || ms === 0) return '--';
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string): string {
|
||||
if (!iso) return '--';
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30 text-[10px]">正常</Badge>;
|
||||
}
|
||||
|
||||
export function SiteMonitor() {
|
||||
@@ -142,108 +52,124 @@ export function SiteMonitor() {
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingTaskId, setEditingTaskId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<CreateTaskForm>({ ...emptyForm });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
|
||||
const [selectedType, setSelectedType] = useState<ProbeType | 'ALL'>('ALL');
|
||||
const [detailTask, setDetailTask] = useState<SiteTask | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const _confirm = useConfirm();
|
||||
|
||||
// ---------- data fetching ----------
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await siteApi.getStats();
|
||||
setStats(data as SiteStats);
|
||||
} catch {
|
||||
// 后端不可用时保留空状态
|
||||
}
|
||||
try { setStats(await siteApi.getStats() as SiteStats); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const data = await siteApi.getTasks();
|
||||
setTasks((data as JsonData)?.list || []);
|
||||
} catch {
|
||||
// 后端不可用时保留空状态
|
||||
} finally {
|
||||
const list = (data as JsonData)?.list as SiteTask[] || [];
|
||||
setTasks(list);
|
||||
setDetailTask((prev) => {
|
||||
if (!prev) return null;
|
||||
return list.find((t) => t.id === prev.id) || null;
|
||||
});
|
||||
} catch { /* ignore */ } finally {
|
||||
setRefreshing(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadStats(); loadTasks(); }, [loadStats, loadTasks]);
|
||||
|
||||
// 30s 静默刷新 // Logan.Li
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadTasks();
|
||||
}, [loadStats, loadTasks]);
|
||||
if (initialLoading && tasks.length === 0) return;
|
||||
const timer = setInterval(() => { loadStats(); loadTasks(); }, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadStats, loadTasks, initialLoading, tasks.length]);
|
||||
|
||||
// ---------- filtered tasks ----------
|
||||
const filteredTasks = selectedType
|
||||
? tasks.filter((t) => t.type?.toUpperCase() === selectedType.toUpperCase())
|
||||
: tasks;
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of PROBE_TYPE_OPTIONS) counts[t] = 0;
|
||||
for (const t of tasks) {
|
||||
const key = normalizeProbeType(t.type);
|
||||
counts[key]++;
|
||||
}
|
||||
return counts;
|
||||
}, [tasks]);
|
||||
|
||||
// ---------- actions ----------
|
||||
const openCreate = () => {
|
||||
setForm({ ...emptyForm });
|
||||
const filteredTasks = selectedType === 'ALL'
|
||||
? tasks
|
||||
: tasks.filter((t) => normalizeProbeType(t.type) === selectedType);
|
||||
|
||||
const openCreate = (type?: ProbeType) => {
|
||||
setEditingTaskId(null);
|
||||
setForm({ ...emptyForm, type: type || (selectedType !== 'ALL' ? selectedType : 'HTTP') });
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
toast.error('名称和 URL 不能为空');
|
||||
return;
|
||||
}
|
||||
const openEdit = (task: SiteTask) => {
|
||||
setEditingTaskId(task.id);
|
||||
setForm({
|
||||
name: task.name, url: task.url,
|
||||
type: normalizeProbeType(task.type),
|
||||
frequency_sec: task.frequency_sec || 60,
|
||||
timeout_sec: task.timeout_sec || 10,
|
||||
expected_status_code: task.expected_status_code || 200,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openDetail = (task: SiteTask) => {
|
||||
setDetailTask(task);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || !form.url.trim()) { toast.error('名称和地址不能为空'); return; }
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
name: form.name.trim(), url: form.url.trim(), type: form.type.toLowerCase(),
|
||||
frequency_sec: Number(form.frequency_sec) || 60,
|
||||
timeout_sec: Number(form.timeout_sec) || 10,
|
||||
expected_status_code: Number(form.expected_status_code) || 200,
|
||||
};
|
||||
try {
|
||||
await siteApi.createTask({
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
type: form.type.toLowerCase(),
|
||||
frequency_sec: Number(form.frequency_sec) || 60,
|
||||
timeout_sec: Number(form.timeout_sec) || 10,
|
||||
expected_status_code: Number(form.expected_status_code) || 200,
|
||||
});
|
||||
toast.success('拨测任务已创建');
|
||||
setFormOpen(false);
|
||||
loadTasks();
|
||||
loadStats();
|
||||
if (editingTaskId) { await siteApi.updateTask(editingTaskId, payload); toast.success('任务已更新'); }
|
||||
else { await siteApi.createTask(payload); toast.success('拨测任务已创建'); }
|
||||
setFormOpen(false); setEditingTaskId(null);
|
||||
loadTasks(); loadStats();
|
||||
} catch (e) {
|
||||
toast.error((e as JsonData).message || '创建失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
toast.error((e as JsonData).message || (editingTaskId ? '更新失败' : '创建失败'));
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (task: SiteTask) => {
|
||||
const ok = await _confirm({
|
||||
title: '删除拨测任务',
|
||||
description: `确定删除「${task.name}」吗?此操作不可恢复。`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
const ok = await _confirm({ title: '删除拨测任务', description: `确定删除「${task.name}」吗?`, variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await siteApi.deleteTask(task.id);
|
||||
if (detailTask?.id === task.id) {
|
||||
setDetailTask(null);
|
||||
setDetailOpen(false);
|
||||
}
|
||||
toast.success('任务已删除');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
toast.error((e as JsonData).message || '删除失败');
|
||||
}
|
||||
loadTasks(); loadStats();
|
||||
} catch (e) { toast.error((e as JsonData).message || '删除失败'); }
|
||||
};
|
||||
|
||||
const handleToggle = async (task: SiteTask) => {
|
||||
try {
|
||||
await siteApi.toggleTask(task.id, !task.enabled);
|
||||
toast.success(task.enabled ? '已停用' : '已启用');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
toast.error((e as JsonData).message || '操作失败');
|
||||
}
|
||||
loadTasks(); loadStats();
|
||||
} catch (e) { toast.error((e as JsonData).message || '操作失败'); }
|
||||
};
|
||||
|
||||
// ---------- render ----------
|
||||
const availStr =
|
||||
stats?.availability != null ? `${(stats.availability * 100).toFixed(1)}%` : '--';
|
||||
const targetMeta = probeTypeMeta[form.type];
|
||||
const availStr = stats?.availability != null ? `${(stats.availability * 100).toFixed(1)}%` : '--';
|
||||
const probeCountStr = stats?.probe_count != null ? String(stats.probe_count) : '--';
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
@@ -254,290 +180,199 @@ export function SiteMonitor() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ===== Probe Type Grid ===== */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{probeTypes.map((p) => {
|
||||
const active = selectedType === p.name;
|
||||
return (
|
||||
<Card
|
||||
key={p.name}
|
||||
onClick={() => setSelectedType(active ? null : p.name)}
|
||||
className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] transition-colors cursor-pointer ${
|
||||
active ? 'border-[var(--dg-blue)]/60 ring-1 ring-[var(--dg-blue)]/30' : 'hover:border-[var(--dg-blue)]/30'
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-3 text-center">
|
||||
<p.icon className={`h-5 w-5 mx-auto mb-1.5 ${p.color}`} />
|
||||
<p className="text-xs font-medium text-[var(--dg-text-primary)]">{p.name}</p>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{p.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">站点拨测</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">HTTP Web 拨测 · SSL 证书检测 · TCP / DNS / PING</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" className="h-8" onClick={() => { loadStats(); loadTasks(); }} disabled={refreshing}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${refreshing ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 gap-1.5" onClick={() => openCreate()}>
|
||||
<Plus className="h-3.5 w-3.5" />添加任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Summary Stats ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">拨测任务概览</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => { loadStats(); loadTasks(); }}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-4 items-start">
|
||||
<div className="xl:col-span-8 space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{[
|
||||
{ label: '总任务数', value: stats?.total_tasks ?? '--' },
|
||||
{ label: '已启用', value: stats?.enabled_tasks ?? '--' },
|
||||
{ label: '总任务', value: stats?.total_tasks ?? '--' },
|
||||
{ label: '正常', value: stats?.up_count ?? '--', color: 'dg-text-green' },
|
||||
{ label: '异常', value: stats?.down_count ?? '--', color: 'dg-text-red' },
|
||||
{ label: '可用率', value: availStr, color: 'dg-text-blue' },
|
||||
{ label: '可用率(24h)', value: availStr, color: 'dg-text-blue' },
|
||||
{ label: '探测次数', value: probeCountStr },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-lg font-bold ${item.color || 'text-[var(--dg-text-primary)]'}`}>
|
||||
{item.value}
|
||||
</p>
|
||||
<div key={item.label} className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-base font-bold ${item.color || 'text-[var(--dg-text-primary)]'}`}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== Task Table ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
拨测任务列表
|
||||
{selectedType && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-2 text-[10px] cursor-pointer border-[var(--dg-blue)]/40 text-[var(--dg-blue)] h-5"
|
||||
onClick={() => setSelectedType(null)}
|
||||
>
|
||||
{selectedType} ×
|
||||
</Badge>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
添加任务
|
||||
</Button>
|
||||
</div>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium">拨测任务</h3>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">点击行查看详情</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{FILTER_TYPES.map((type) => {
|
||||
const count = type === 'ALL' ? tasks.length : typeCounts[type] || 0;
|
||||
const active = selectedType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--dg-blue)]/15 text-[var(--dg-blue)] border border-[var(--dg-blue)]/30'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border border-transparent hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{type === 'ALL' ? '全部' : type} {count > 0 && count}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="text-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
{tasks.length === 0
|
||||
? '暂无拨测任务,点击「添加任务」创建第一个'
|
||||
: '当前筛选条件下无任务'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">URL</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">类型</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">间隔</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">状态</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">延迟</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">上次检测</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">启用</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTasks.map((task) => (
|
||||
<TableRow key={task.id} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-sm text-[var(--dg-text-primary)] font-medium">
|
||||
{task.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)] max-w-[200px] truncate">
|
||||
{task.url}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[11px] border-[var(--dg-border)]">
|
||||
{task.type?.toUpperCase()}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||
{task.frequency_sec}s
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{statusIcon(task.last_status)}
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">
|
||||
{statusBadge(task.last_status)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||
{fmtMs(task.last_response_time_ms)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||
{fmtTime(task.last_checked_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={task.enabled}
|
||||
onCheckedChange={() => handleToggle(task)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 dg-text-red hover:dg-text-red hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(task)}
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="text-center py-10 text-sm text-[var(--dg-text-muted)]">
|
||||
{tasks.length === 0 ? '暂无拨测任务' : '当前筛选无任务'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-xs">名称</TableHead>
|
||||
<TableHead className="text-xs">类型</TableHead>
|
||||
<TableHead className="text-xs">频率</TableHead>
|
||||
<TableHead className="text-xs">报警状态</TableHead>
|
||||
<TableHead className="text-xs">可用率/响应时间</TableHead>
|
||||
<TableHead className="text-xs">启用</TableHead>
|
||||
<TableHead className="text-xs w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTasks.map((task) => (
|
||||
<TableRow
|
||||
key={task.id}
|
||||
className="border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-tertiary)]/60"
|
||||
onClick={() => openDetail(task)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TableCell className="py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{task.name}</span>
|
||||
{isSslProbeTask(task) && isSslExpiringSoon(task.ssl_days_remaining) && (
|
||||
<Badge className="bg-amber-500/15 dg-text-yellow border-amber-500/30 text-[10px] shrink-0 gap-0.5">
|
||||
<ShieldAlert className="h-3 w-3" />证书
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] truncate max-w-[220px]">{task.url}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<Badge variant="outline" className="text-[10px]">{normalizeProbeType(task.type)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] py-2">{fmtFrequency(task.frequency_sec)}</TableCell>
|
||||
<TableCell className="py-2">{alertStatusBadge(task.alert_status)}</TableCell>
|
||||
<TableCell className="text-xs font-mono py-2">{fmtAvailResponse(task)}</TableCell>
|
||||
<TableCell className="py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch checked={task.enabled} onCheckedChange={() => handleToggle(task)} />
|
||||
</TableCell>
|
||||
<TableCell className="py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(task)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" className="dg-text-red" onClick={() => handleDelete(task)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ===== Create Task Dialog ===== */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<div className="xl:col-span-4">
|
||||
<SiteProtocolPieChart tasks={tasks} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteTaskDetailDialog
|
||||
task={detailTask}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
onProbed={() => { loadTasks(); loadStats(); }}
|
||||
/>
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={(open) => { setFormOpen(open); if (!open) setEditingTaskId(null); }}>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加拨测任务</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
配置站点探活任务,系统将按指定频率自动检测
|
||||
</DialogDescription>
|
||||
<DialogTitle>{editingTaskId ? '编辑拨测任务' : '添加拨测任务'}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">按协议类型配置探活任务,系统将按间隔自动拨测</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{/* 名称 */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="task-name" className="text-[var(--dg-text-primary)]">任务名称</Label>
|
||||
<Input
|
||||
id="task-name"
|
||||
placeholder="例如:官网首页检测"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<Label>任务名称</Label>
|
||||
<Input placeholder="例如:官网首页" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="task-url" className="text-[var(--dg-text-primary)]">目标 URL</Label>
|
||||
<Input
|
||||
id="task-url"
|
||||
placeholder="例如:https://example.com 或 192.168.1.1"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型 */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">协议类型</Label>
|
||||
<Select
|
||||
value={form.type}
|
||||
onValueChange={(v) => setForm({ ...form, type: v as ProbeType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<Label>协议类型</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v as ProbeType })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROBE_TYPE_OPTIONS.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
{PROBE_TYPE_OPTIONS.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{targetMeta.hint}</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{targetMeta.label}</Label>
|
||||
<Input placeholder={targetMeta.placeholder} value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{/* 频率 & 超时 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="freq" className="text-[var(--dg-text-primary)]">
|
||||
检测间隔 (秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="freq"
|
||||
type="number"
|
||||
min={10}
|
||||
max={86400}
|
||||
value={form.frequency_sec}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, frequency_sec: Number(e.target.value) || 60 })
|
||||
}
|
||||
/>
|
||||
<Label>检测间隔 (秒)</Label>
|
||||
<Input type="number" min={10} max={86400} value={form.frequency_sec}
|
||||
onChange={(e) => setForm({ ...form, frequency_sec: Number(e.target.value) || 60 })} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="timeout" className="text-[var(--dg-text-primary)]">
|
||||
超时 (秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={form.timeout_sec}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, timeout_sec: Number(e.target.value) || 10 })
|
||||
}
|
||||
/>
|
||||
<Label>超时 (秒)</Label>
|
||||
<Input type="number" min={1} max={300} value={form.timeout_sec}
|
||||
onChange={(e) => setForm({ ...form, timeout_sec: Number(e.target.value) || 10 })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 期望状态码 (仅 HTTP/HTTPS 显示) */}
|
||||
{(form.type === 'HTTP' || form.type === 'HTTPS') && (
|
||||
{form.type === 'HTTP' && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="status-code" className="text-[var(--dg-text-primary)]">
|
||||
期望状态码
|
||||
</Label>
|
||||
<Input
|
||||
id="status-code"
|
||||
type="number"
|
||||
min={100}
|
||||
max={599}
|
||||
value={form.expected_status_code}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, expected_status_code: Number(e.target.value) || 200 })
|
||||
}
|
||||
/>
|
||||
<Label>期望状态码</Label>
|
||||
<Input type="number" min={100} max={599} value={form.expected_status_code}
|
||||
onChange={(e) => setForm({ ...form, expected_status_code: Number(e.target.value) || 200 })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
<Button variant="outline" onClick={() => { setFormOpen(false); setEditingTaskId(null); }} disabled={saving}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
创建任务
|
||||
{editingTaskId ? '保存' : '创建任务'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user