- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖 - 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败 - Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS - 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率 - 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示 - 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
402 lines
19 KiB
TypeScript
402 lines
19 KiB
TypeScript
// 站点拨测 — 任务列表 + 详情页可观测 // Logan.Li
|
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
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 { Switch } from '@/components/ui/switch';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
Plus, Trash2, Pencil, Loader2, RefreshCw, Activity, ShieldAlert, LineChart,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { siteApi } from '@/api';
|
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
|
import type { JsonData } from '@/types/common';
|
|
import { SiteProtocolPieChart } from './site-monitor/SiteProtocolPieChart';
|
|
import { SiteTaskDetailPage } from './site-monitor/SiteTaskDetailPage';
|
|
import {
|
|
PROBE_TYPE_OPTIONS, type ProbeType, type SiteStats, type SiteTask,
|
|
probeTypeMeta, probeFrequencyHint, defaultProbeFrequency, MIN_PROBE_FREQUENCY, 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;
|
|
}
|
|
|
|
const emptyForm: CreateTaskForm = {
|
|
name: '', url: '', type: 'HTTP',
|
|
frequency_sec: defaultProbeFrequency('HTTP'), timeout_sec: 10, expected_status_code: 200,
|
|
};
|
|
|
|
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>;
|
|
}
|
|
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30 text-[10px]">正常</Badge>;
|
|
}
|
|
|
|
export function SiteMonitor() {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const taskIdParam = searchParams.get('task');
|
|
const [stats, setStats] = useState<SiteStats | null>(null);
|
|
const [tasks, setTasks] = useState<SiteTask[]>([]);
|
|
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<ProbeType | 'ALL'>('ALL');
|
|
const _confirm = useConfirm();
|
|
|
|
const detailTask = useMemo(() => {
|
|
if (!taskIdParam) return null;
|
|
const id = Number(taskIdParam);
|
|
if (!Number.isFinite(id)) return null;
|
|
return tasks.find((t) => t.id === id) || null;
|
|
}, [taskIdParam, tasks]);
|
|
|
|
const loadStats = useCallback(async () => {
|
|
try { setStats(await siteApi.getStats() as SiteStats); } catch { /* ignore */ }
|
|
}, []);
|
|
|
|
const loadTasks = useCallback(async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
const data = await siteApi.getTasks();
|
|
const list = (data as JsonData)?.list as SiteTask[] || [];
|
|
setTasks(list);
|
|
} catch { /* ignore */ } finally {
|
|
setRefreshing(false);
|
|
setInitialLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { loadStats(); loadTasks(); }, [loadStats, loadTasks]);
|
|
|
|
// 30s 静默刷新 // Logan.Li
|
|
useEffect(() => {
|
|
if (initialLoading && tasks.length === 0) return;
|
|
const timer = setInterval(() => { loadStats(); loadTasks(); }, 30_000);
|
|
return () => clearInterval(timer);
|
|
}, [loadStats, loadTasks, initialLoading, tasks.length]);
|
|
|
|
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]);
|
|
|
|
const filteredTasks = selectedType === 'ALL'
|
|
? tasks
|
|
: tasks.filter((t) => normalizeProbeType(t.type) === selectedType);
|
|
|
|
const openCreate = (type?: ProbeType) => {
|
|
setEditingTaskId(null);
|
|
const t = type || (selectedType !== 'ALL' ? selectedType : 'HTTP');
|
|
setForm({ ...emptyForm, type: t, frequency_sec: defaultProbeFrequency(t) });
|
|
setFormOpen(true);
|
|
};
|
|
|
|
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) => {
|
|
setSearchParams({ tab: 'site', task: String(task.id) });
|
|
};
|
|
|
|
const closeDetail = () => {
|
|
setSearchParams({ tab: 'site' });
|
|
};
|
|
|
|
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 {
|
|
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 || (editingTaskId ? '更新失败' : '创建失败'));
|
|
} finally { setSaving(false); }
|
|
};
|
|
|
|
const handleDelete = async (task: SiteTask) => {
|
|
const ok = await _confirm({ title: '删除拨测任务', description: `确定删除「${task.name}」吗?`, variant: 'destructive' });
|
|
if (!ok) return;
|
|
try {
|
|
await siteApi.deleteTask(task.id);
|
|
if (taskIdParam === String(task.id)) {
|
|
closeDetail();
|
|
}
|
|
toast.success('任务已删除');
|
|
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 || '操作失败'); }
|
|
};
|
|
|
|
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 (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="h-8 w-8 animate-spin text-[var(--dg-text-muted)]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (taskIdParam) {
|
|
if (!detailTask) {
|
|
return (
|
|
<div className="space-y-4 text-center py-16">
|
|
<p className="text-sm text-[var(--dg-text-muted)]">拨测任务不存在或已删除</p>
|
|
<Button variant="outline" size="sm" onClick={closeDetail}>返回列表</Button>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<SiteTaskDetailPage
|
|
task={detailTask}
|
|
onBack={closeDetail}
|
|
onProbed={() => { loadTasks(); loadStats(); }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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>
|
|
|
|
<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?.up_count ?? '--', color: 'dg-text-green' },
|
|
{ label: '异常', value: stats?.down_count ?? '--', color: 'dg-text-red' },
|
|
{ label: '可用率(24h)', value: availStr, color: 'dg-text-blue' },
|
|
{ label: '探测次数', value: probeCountStr },
|
|
].map((item) => (
|
|
<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>
|
|
|
|
<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-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)] hover:bg-[var(--dg-bg-tertiary)]/60"
|
|
>
|
|
<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">
|
|
<div className="flex items-center justify-end gap-0.5">
|
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => openDetail(task)}>
|
|
<LineChart className="h-3 w-3 mr-1" />可观测
|
|
</Button>
|
|
<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>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="xl:col-span-4">
|
|
<SiteProtocolPieChart tasks={tasks} />
|
|
</div>
|
|
</div>
|
|
|
|
<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>{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>任务名称</Label>
|
|
<Input placeholder="例如:官网首页" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<Label>协议类型</Label>
|
|
<Select value={form.type} onValueChange={(v) => {
|
|
const t = v as ProbeType;
|
|
setForm({ ...form, type: t, frequency_sec: editingTaskId ? form.frequency_sec : defaultProbeFrequency(t) });
|
|
}}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{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>检测间隔 (秒)</Label>
|
|
<Input type="number" min={MIN_PROBE_FREQUENCY[form.type]} max={86400} value={form.frequency_sec}
|
|
onChange={(e) => setForm({ ...form, frequency_sec: Number(e.target.value) || defaultProbeFrequency(form.type) })} />
|
|
<p className="text-[10px] text-[var(--dg-text-muted)]">{probeFrequencyHint(form.type)}</p>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<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>
|
|
{form.type === 'HTTP' && (
|
|
<div className="grid gap-1.5">
|
|
<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); 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>
|
|
);
|
|
} |