feat: v0.34 健康探针(Architecture B) + Agent v2.1 + 端口监控 + 告警联系人重构
- v0.31: AlertContact 重构(channel_type下拉+webhook_url替代三分立字段) - v0.32: Agent ProcessTop 增强(cmdline/user/cpu_pct/mem_pct) - v0.33: 端口监控 Tab(监听端口自动发现+down联动告警) - v0.34: 健康探针(Agent本地执行HTTP/TCP/Exec→结果随心跳上报) - Agent新增: ContextSwitch/FD/HealthProbe采集器 - 后端新增: health_probe handler + Agent model扩展 - HostMonitor新增Tab: 健康探针/端口监控
This commit is contained in:
1 parent
e1f75f0bc4
commit
d656520428
25 files changed
+1700
-619
No files matched your search
@@ -18,9 +18,9 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertCenterApi, agentApi, alertApi } from '@/services/api';
|
||||
import {
|
||||
type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge,
|
||||
getChannelTypeLabel,
|
||||
} from './alert-center-helpers';
|
||||
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
||||
import { AlertChannelsTab } from './AlertChannelsTab';
|
||||
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
|
||||
import { ContactDialog } from './ContactDialog';
|
||||
import { ContactGroupDialog } from './ContactGroupDialog';
|
||||
@@ -265,7 +265,7 @@ const AlertCenter: React.FC = () => {
|
||||
<TabsList className="grid w-full grid-cols-4 lg:w-[480px] h-9">
|
||||
<TabsTrigger value="alerts" className="text-xs">告警列表</TabsTrigger>
|
||||
<TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger>
|
||||
<TabsTrigger value="contacts" className="text-xs">通知管理</TabsTrigger>
|
||||
<TabsTrigger value="contacts" className="text-xs">报警联系人</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="text-xs">报警联系组</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -501,11 +501,9 @@ const AlertCenter: React.FC = () => {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 通知管理 Tab — 渠道 + 联系人合并 */}
|
||||
{/* 报警联系人 Tab */}
|
||||
<TabsContent value="contacts" className="space-y-4">
|
||||
<AlertChannelsTab refreshKey={0} />
|
||||
<div className="border-t border-border pt-4 mt-1">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium">报警联系人</h3>
|
||||
<Button size="sm" onClick={() => { setEditingContact(null); setContactDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
@@ -529,9 +527,16 @@ const AlertCenter: React.FC = () => {
|
||||
{c.phone && <div>📱 {c.phone}</div>}
|
||||
{c.email && <div>📧 {c.email}</div>}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{c.dingtalk && <Badge variant="outline" className="text-[10px]">钉钉</Badge>}
|
||||
{c.feishu && <Badge variant="outline" className="text-[10px]">飞书</Badge>}
|
||||
{c.wechat && <Badge variant="outline" className="text-[10px]">企微</Badge>}
|
||||
{c.channel_type && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{getChannelTypeLabel(c.channel_type)}
|
||||
</Badge>
|
||||
)}
|
||||
{c.webhook_url && (
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[200px]" title={c.webhook_url}>
|
||||
{c.webhook_url}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 pt-1.5">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs flex-1"
|
||||
@@ -553,7 +558,6 @@ const AlertCenter: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 报警联系组 Tab */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 报警联系人创建/编辑对话框 // Logan.Li
|
||||
// 报警联系人创建/编辑对话框 — 渠道类型下拉 + webhook URL // Logan.Li
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
@@ -6,10 +6,20 @@ import {
|
||||
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 { toast } from 'sonner';
|
||||
import { alertApi } from '@/services/api';
|
||||
import type { Contact } from './alert-center-helpers';
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'feishu', label: '飞书', placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/...' },
|
||||
{ value: 'dingtalk', label: '钉钉', placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=...' },
|
||||
{ value: 'wechat', label: '企业微信', placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' },
|
||||
{ value: 'webhook', label: '自定义Webhook', placeholder: 'https://your-webhook-url...' },
|
||||
];
|
||||
|
||||
export function ContactDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -21,39 +31,37 @@ export function ContactDialog({
|
||||
editingContact: Contact | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
name: '', phone: '', email: '', dingtalk: '', feishu: '', wechat: '',
|
||||
});
|
||||
const [channelType, setChannelType] = useState('feishu');
|
||||
const [form, setForm] = useState({ name: '', phone: '', email: '', webhook_url: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (editingContact) {
|
||||
setChannelType(editingContact.channel_type || 'feishu');
|
||||
setForm({
|
||||
name: editingContact.name || '',
|
||||
phone: editingContact.phone || '',
|
||||
email: editingContact.email || '',
|
||||
dingtalk: editingContact.dingtalk || '',
|
||||
feishu: editingContact.feishu || '',
|
||||
wechat: editingContact.wechat || '',
|
||||
webhook_url: editingContact.webhook_url || '',
|
||||
});
|
||||
} else {
|
||||
setForm({ name: '', phone: '', email: '', dingtalk: '', feishu: '', wechat: '' });
|
||||
setChannelType('feishu');
|
||||
setForm({ name: '', phone: '', email: '', webhook_url: '' });
|
||||
}
|
||||
}
|
||||
}, [open, editingContact]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) return;
|
||||
if (!form.name.trim() || !form.webhook_url.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
dingtalk: form.dingtalk.trim() || undefined,
|
||||
feishu: form.feishu.trim() || undefined,
|
||||
wechat: form.wechat.trim() || undefined,
|
||||
channel_type: channelType,
|
||||
webhook_url: form.webhook_url.trim(),
|
||||
};
|
||||
if (editingContact) {
|
||||
await alertApi.updateContact(editingContact.id, payload);
|
||||
@@ -71,12 +79,14 @@ export function ContactDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const selectedChannel = CHANNEL_OPTIONS.find(c => c.value === channelType);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingContact ? '编辑报警联系人' : '添加报警联系人'}</DialogTitle>
|
||||
<DialogDescription>配置报警联系人信息(姓名必填,渠道选填)</DialogDescription>
|
||||
<DialogDescription>选择通知渠道并填入 Webhook 地址</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -87,53 +97,50 @@ export function ContactDialog({
|
||||
placeholder="如:张三"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder="如:13800138000"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder="13800138000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱</Label>
|
||||
<Input
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
placeholder="zhangsan@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱</Label>
|
||||
<Input
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
placeholder="如:zhangsan@example.com"
|
||||
/>
|
||||
<Label>渠道类型 *</Label>
|
||||
<Select value={channelType} onValueChange={setChannelType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择渠道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHANNEL_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>钉钉(webhook URL)</Label>
|
||||
<Label>Webhook URL *</Label>
|
||||
<Input
|
||||
value={form.dingtalk}
|
||||
onChange={(e) => setForm({ ...form, dingtalk: e.target.value })}
|
||||
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>飞书(webhook URL)</Label>
|
||||
<Input
|
||||
value={form.feishu}
|
||||
onChange={(e) => setForm({ ...form, feishu: e.target.value })}
|
||||
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>企业微信(webhook URL)</Label>
|
||||
<Input
|
||||
value={form.wechat}
|
||||
onChange={(e) => setForm({ ...form, wechat: e.target.value })}
|
||||
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
|
||||
className="font-mono text-sm"
|
||||
value={form.webhook_url}
|
||||
onChange={(e) => setForm({ ...form, webhook_url: e.target.value })}
|
||||
placeholder={selectedChannel?.placeholder || 'https://...'}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={!form.name.trim() || saving}>
|
||||
<Button onClick={handleSave} disabled={!form.name.trim() || !form.webhook_url.trim() || saving}>
|
||||
{saving ? '保存中...' : editingContact ? '保存修改' : '添加联系人'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -60,9 +60,8 @@ export interface Contact {
|
||||
name: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
dingtalk?: string;
|
||||
feishu?: string;
|
||||
wechat?: string;
|
||||
channel_type?: string;
|
||||
webhook_url?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Cpu, HardDrive, Network, Activity, Server, Loader2,
|
||||
Info, Monitor, Disc, Zap, Clock, List, AlertTriangle,
|
||||
Info, Monitor, Disc, Clock, List, HeartPulse, Plus, Trash2, RefreshCw, Edit, CheckCircle, XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
@@ -81,12 +81,36 @@ export function HostMonitor() {
|
||||
const [latest, setLatest] = useState<any>(null);
|
||||
const [serverInfo, setServerInfo] = useState<any>(null);
|
||||
|
||||
// 健康探针
|
||||
const [probes, setProbes] = useState<any[]>([]);
|
||||
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
|
||||
const [editingProbe, setEditingProbe] = useState<any>(null);
|
||||
const [probeForm, setProbeForm] = useState({
|
||||
name: '', hostname: '', probe_type: 'http_get', http_url: '', http_method: 'GET',
|
||||
http_expect_code: 200, http_expect_body: '',
|
||||
tcp_host: '', tcp_port: 3306, exec_command: '', timeout: 10, interval: 60, description: '',
|
||||
});
|
||||
|
||||
// 加载探针
|
||||
|
||||
const fetchProbes = useCallback(() => {
|
||||
if (!selectedHost) return;
|
||||
agentApi.getHealthProbes({ hostname: selectedHost }).then((data: any) => {
|
||||
if (Array.isArray(data)) setProbes(data);
|
||||
else if (data?.list) setProbes(data.list);
|
||||
else setProbes([]);
|
||||
}).catch(() => setProbes([]));
|
||||
}, [selectedHost]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProbes();
|
||||
}, [fetchProbes]);
|
||||
|
||||
const timeConfig = TIME_RANGES.find(tr => tr.hours === rangeHours) || TIME_RANGES[0];
|
||||
|
||||
useEffect(() => {
|
||||
agentApi.getLatest().then((data: any) => {
|
||||
const list = Array.isArray(data) ? data : (data?.data || data?.list || []);
|
||||
setHosts(list);
|
||||
if (list.length > 0 && !selectedHost) {
|
||||
const m = urlHost ? list.find((h: any) => h.hostname === urlHost) : null;
|
||||
setSelectedHost(m?.hostname || list[0]?.hostname || '');
|
||||
@@ -241,8 +265,10 @@ export function HostMonitor() {
|
||||
{ id: 'basic', label: '基础监控', icon: <Monitor className="w-3 h-3" /> },
|
||||
{ id: 'disk', label: '磁盘监控', icon: <HardDrive className="w-3 h-3" /> },
|
||||
{ id: 'network', label: '网络监控', icon: <Network className="w-3 h-3" /> },
|
||||
{ id: 'ports', label: '端口监控', icon: <Server className="w-3 h-3" /> },
|
||||
{ id: 'process', label: '进程监控', icon: <List className="w-3 h-3" /> },
|
||||
{ id: 'system', label: '系统信息', icon: <Info className="w-3 h-3" /> },
|
||||
{ id: 'health_probes', label: '健康探针', icon: <HeartPulse className="w-3 h-3" /> },
|
||||
].map(tab => (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1 data-[state=active]:bg-[var(--dg-bg-primary)]">
|
||||
{tab.icon}{tab.label}
|
||||
@@ -290,103 +316,135 @@ export function HostMonitor() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══ 进程监控 — v2.0 新增 ═══ */}
|
||||
<TabsContent value="process" className="mt-3 space-y-3">
|
||||
{/* 进程 TOP 表格 */}
|
||||
{/* ═══ 端口监控 ═══ */}
|
||||
<TabsContent value="ports" className="mt-3 space-y-3">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
|
||||
<List className="h-4 w-4 text-cyan-400" />进程 TOP {processTop.length} (按内存排序)
|
||||
<Server className="h-4 w-4 text-emerald-400" />监听端口 {portInfo.length}
|
||||
</h3>
|
||||
{processTop.length === 0 ? (
|
||||
{portInfo.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
Agent v2.0+ 支持,当前无数据
|
||||
暂无端口数据
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="py-1.5 text-left font-medium">PID</th>
|
||||
<th className="py-1.5 text-left font-medium">进程名</th>
|
||||
<th className="py-1.5 text-right font-medium">内存 (MB)</th>
|
||||
<th className="py-1.5 text-right font-medium">FD 数</th>
|
||||
<th className="py-1.5 text-right font-medium">线程数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processTop.map((p: any, i: number) => (
|
||||
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30">
|
||||
<td className="py-1.5 font-mono text-[var(--dg-text-muted)]">{p.pid}</td>
|
||||
<td className="py-1.5 font-mono text-[var(--dg-text-primary)]">{p.name}</td>
|
||||
<td className="py-1.5 text-right font-mono text-cyan-400">{(p.memory_mb ?? 0).toFixed(0)}</td>
|
||||
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.fd_count ?? 0}</td>
|
||||
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.threads ?? 0}</td>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="py-1.5 text-left font-medium">端口</th>
|
||||
<th className="py-1.5 text-left font-medium">协议</th>
|
||||
<th className="py-1.5 text-left font-medium">进程</th>
|
||||
<th className="py-1.5 text-right font-medium">状态</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{portInfo.map((p: any, i: number) => (
|
||||
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30 transition-colors">
|
||||
<td className="py-1.5 font-mono text-[var(--dg-text-primary)]">{p.port}</td>
|
||||
<td className="py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{p.protocol || 'tcp'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-1.5 font-mono text-[var(--dg-text-muted)]">{p.process || '—'}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
<Badge variant="default" className="text-[10px] bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
|
||||
LISTEN
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Swap + FD + CS 快速卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<MetricChartCard title="Swap 使用率(%)" value={currentVal('swap')}
|
||||
icon={<AlertTriangle className="h-4 w-4 text-rose-400" />}
|
||||
data={metrics.swap} color="#f43f5e" unit="%" domain={[0, 100]} aggType={aggType}
|
||||
/>
|
||||
<MetricChartCard title="FD 句柄使用率(%)" value={currentVal('fd')}
|
||||
icon={<Activity className="h-4 w-4 text-cyan-400" />}
|
||||
data={metrics.fd} color="#22d3ee" unit="%" domain={[0, 100]} aggType={aggType}
|
||||
/>
|
||||
{/* CS 当前值卡片 */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Zap className="h-4 w-4 text-indigo-400" /><span className="text-xs text-[var(--dg-text-primary)]">上下文切换</span>
|
||||
{/* ═══ 进程监控 — 简洁 5 列 ═══ */}
|
||||
<TabsContent value="process" className="mt-3 space-y-3">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
|
||||
<List className="h-4 w-4 text-cyan-400" />进程 TOP {processTop.length}
|
||||
</h3>
|
||||
{processTop.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
Agent v2.0+ 支持,等待数据上报…
|
||||
</div>
|
||||
<div className="text-xl font-bold text-[var(--dg-text-primary)] tabular-nums">
|
||||
{csInfo?.ctx_per_sec != null ? `${(csInfo.ctx_per_sec / 1000).toFixed(1)}K/s` : '--'}
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="py-1.5 text-left font-medium w-[40%]">进程名称 / CMD</th>
|
||||
<th className="py-1.5 text-right font-medium w-[8%]">PID</th>
|
||||
<th className="py-1.5 text-right font-medium w-[16%]">内存使用率(%)</th>
|
||||
<th className="py-1.5 text-right font-medium w-[16%]">CPU使用率(%)</th>
|
||||
<th className="py-1.5 text-right font-medium w-[20%]">打开文件数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processTop.map((p: any, i: number) => {
|
||||
const cmd = p.cmdline || p.name || '?';
|
||||
const cpup = p.cpu_pct != null ? Number(p.cpu_pct) : null;
|
||||
const memp = p.mem_pct != null ? Number(p.mem_pct) : null;
|
||||
return (
|
||||
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30 transition-colors">
|
||||
<td className="py-1.5 pr-2">
|
||||
<div className="flex items-center gap-1 group/cmd">
|
||||
<span
|
||||
className="font-mono text-[var(--dg-text-primary)] truncate max-w-[320px] inline-block align-middle"
|
||||
title={cmd}
|
||||
>
|
||||
{cmd}
|
||||
</span>
|
||||
<button
|
||||
className="opacity-0 group-hover/cmd:opacity-100 transition-opacity p-0.5 hover:text-cyan-400 shrink-0"
|
||||
title="复制命令行"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = cmd; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
document.execCommand('copy'); document.body.removeChild(ta);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.pid}</td>
|
||||
<td className="py-1.5 text-right font-mono">
|
||||
<span className={memp != null && memp > 50 ? 'text-rose-400' : 'text-cyan-400'}>
|
||||
{memp != null ? memp.toFixed(2) : '--'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono">
|
||||
<span className={cpup != null && cpup > 50 ? 'text-rose-400' : 'text-[var(--dg-text-primary)]'}>
|
||||
{cpup != null ? cpup.toFixed(2) : '--'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.fd_count ?? '--'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">
|
||||
中断: {csInfo?.irq_per_sec != null ? `${(csInfo.irq_per_sec / 1000).toFixed(1)}K/s` : '--'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* FD / Swap 详情 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{fdInfo && fdInfo.max > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">文件句柄</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-bold text-[var(--dg-text-primary)]">{fdInfo.allocated}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">/ {fdInfo.max}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full bg-cyan-500" style={{ width: `${Math.min(fdInfo.usage_pct || 0, 100)}%` }} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{swapInfo && swapInfo.total_gb > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">Swap</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-bold text-[var(--dg-text-primary)]">{(swapInfo.used_gb ?? 0).toFixed(1)}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">/ {(swapInfo.total_gb ?? 0).toFixed(0)} GB</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${(swapInfo.usage_percent || 0) > 50 ? 'bg-rose-500' : 'bg-amber-500'}`}
|
||||
style={{ width: `${Math.min(swapInfo.usage_percent || 0, 100)}%` }} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 系统信息 */}
|
||||
@@ -460,7 +518,183 @@ export function HostMonitor() {
|
||||
);
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 健康探针 — Agent 本地执行 + 结果上报 */}
|
||||
<TabsContent value="health_probes" className="mt-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">探针列表(Agent 在目标主机本地执行探测)</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={fetchProbes}>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />刷新
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={() => {
|
||||
setEditingProbe(null);
|
||||
setProbeForm({
|
||||
name: '', hostname: selectedHost, probe_type: 'http_get', http_url: '',
|
||||
http_method: 'GET', http_expect_code: 200, http_expect_body: '',
|
||||
tcp_host: 'localhost', tcp_port: 3306, exec_command: '', timeout: 10, interval: 60, description: '',
|
||||
});
|
||||
setProbeDialogOpen(true);
|
||||
}}>
|
||||
<Plus className="w-3 h-3 mr-1" />添加探针
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{probes.length === 0 ? (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-dashed border-[var(--dg-border)]">
|
||||
<CardContent className="p-8 text-center text-xs text-[var(--dg-text-muted)]">
|
||||
暂无健康探针,点击"添加探针"创建
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{probes.map((p: any) => (
|
||||
<Card key={p.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${p.last_status === 'down' ? 'border-red-700/50' : ''}`}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{p.last_status === 'up' ? <CheckCircle className="w-4 h-4 text-emerald-400" /> :
|
||||
p.last_status === 'down' ? <XCircle className="w-4 h-4 text-red-400" /> :
|
||||
<Clock className="w-4 h-4 text-slate-500" />}
|
||||
<span className="text-xs font-medium text-[var(--dg-text-primary)]">{p.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-5 px-1.5 capitalize">
|
||||
{p.probe_type === 'http_get' ? 'HTTP' : p.probe_type === 'tcp_socket' ? 'TCP' : 'Exec'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => {
|
||||
setEditingProbe(p);
|
||||
setProbeForm({
|
||||
name: p.name, hostname: p.hostname, probe_type: p.probe_type,
|
||||
http_url: p.http_url || '', http_method: p.http_method || 'GET',
|
||||
http_expect_code: p.http_expect_code || 200, http_expect_body: p.http_expect_body || '',
|
||||
tcp_host: p.tcp_host || '', tcp_port: p.tcp_port || 3306,
|
||||
exec_command: p.exec_command || '', timeout: p.timeout || 10,
|
||||
interval: p.interval || 60, description: p.description || '',
|
||||
});
|
||||
setProbeDialogOpen(true);
|
||||
}}>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400 hover:text-red-300" onClick={async () => {
|
||||
if (confirm(`确定删除探针"${p.name}"?`)) {
|
||||
await agentApi.deleteHealthProbe(p.id);
|
||||
fetchProbes();
|
||||
}
|
||||
}}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-[11px] text-[var(--dg-text-muted)]">
|
||||
{p.probe_type === 'http_get' && (
|
||||
<div className="truncate">URL: {p.http_url}</div>
|
||||
)}
|
||||
{p.probe_type === 'tcp_socket' && (
|
||||
<div>{p.tcp_host}:{p.tcp_port}</div>
|
||||
)}
|
||||
{p.probe_type === 'exec_command' && (
|
||||
<div className="truncate font-mono">{p.exec_command}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span>状态: <span className={p.last_status === 'up' ? 'text-emerald-400' : p.last_status === 'down' ? 'text-red-400' : 'text-slate-400'}>
|
||||
{p.last_status ? p.last_status.toUpperCase() : '未检测'}
|
||||
</span></span>
|
||||
{p.last_latency_ms > 0 && <span>延迟: {p.last_latency_ms}ms</span>}
|
||||
{p.fail_count > 0 && <span className="text-red-400">连续失败: {p.fail_count}次</span>}
|
||||
{p.last_checked_at && <span className="text-[10px]">{new Date(p.last_checked_at).toLocaleString('zh-CN')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 添加/编辑探针对话框 */}
|
||||
{probeDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setProbeDialogOpen(false)}>
|
||||
<div className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg p-5 w-[480px] max-h-[90vh] overflow-y-auto shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-4">
|
||||
{editingProbe ? '编辑探针' : '添加探针'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针名称 <span className="text-red-400">*</span></label>
|
||||
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.name} onChange={e => setProbeForm({ ...probeForm, name: e.target.value })} placeholder="如:cex-agent 健康检查" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">目标主机</label>
|
||||
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.hostname} onChange={e => setProbeForm({ ...probeForm, hostname: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针类型</label>
|
||||
<select className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.probe_type} onChange={e => setProbeForm({ ...probeForm, probe_type: e.target.value })}>
|
||||
<option value="http_get">HTTP GET</option>
|
||||
<option value="tcp_socket">TCP Socket</option>
|
||||
<option value="exec_command">Exec Command</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{probeForm.probe_type === 'http_get' && (
|
||||
<>
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">HTTP URL <span className="text-red-400">*</span></label>
|
||||
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_url} onChange={e => setProbeForm({ ...probeForm, http_url: e.target.value })} placeholder="http://localhost:8080/health" /></div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">期望状态码</label>
|
||||
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_code} onChange={e => setProbeForm({ ...probeForm, http_expect_code: Number(e.target.value) })} /></div>
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">期望响应内容(可选)</label>
|
||||
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_body} onChange={e => setProbeForm({ ...probeForm, http_expect_body: e.target.value })} /></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{probeForm.probe_type === 'tcp_socket' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 目标地址</label>
|
||||
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_host} onChange={e => setProbeForm({ ...probeForm, tcp_host: e.target.value })} placeholder="localhost" /></div>
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 端口</label>
|
||||
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_port} onChange={e => setProbeForm({ ...probeForm, tcp_port: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{probeForm.probe_type === 'exec_command' && (
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">执行命令 <span className="text-red-400">*</span></label>
|
||||
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs font-mono text-[var(--dg-text-primary)]" value={probeForm.exec_command} onChange={e => setProbeForm({ ...probeForm, exec_command: e.target.value })} placeholder="pgrep -x mysqld" /></div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">超时(秒)</label>
|
||||
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.timeout} onChange={e => setProbeForm({ ...probeForm, timeout: Number(e.target.value) })} /></div>
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">间隔(秒)</label>
|
||||
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.interval} onChange={e => setProbeForm({ ...probeForm, interval: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
|
||||
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">描述</label>
|
||||
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.description} onChange={e => setProbeForm({ ...probeForm, description: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setProbeDialogOpen(false)}>取消</Button>
|
||||
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={async () => {
|
||||
if (!probeForm.name || !probeForm.hostname) return;
|
||||
try {
|
||||
if (editingProbe) {
|
||||
await agentApi.updateHealthProbe(editingProbe.id, probeForm);
|
||||
} else {
|
||||
await agentApi.createHealthProbe(probeForm);
|
||||
}
|
||||
setProbeDialogOpen(false);
|
||||
fetchProbes();
|
||||
} catch (e) { alert('保存失败'); }
|
||||
}} disabled={!probeForm.name || !probeForm.hostname}>
|
||||
{editingProbe ? '保存' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -885,6 +885,11 @@ export const agentApi = {
|
||||
batchUpgrade: (serverIds: number[]) => http.post('/agent/batch-upgrade', { server_ids: serverIds }),
|
||||
listAlerts: (params?: Record<string, string>) => http.get('/agent/alerts', params),
|
||||
resolveAlert: (id: number) => http.post(`/agent/alerts/${id}/resolve`),
|
||||
// 健康探针
|
||||
getHealthProbes: (params?: Record<string, string>) => http.get('/agent/health-probes', params),
|
||||
createHealthProbe: (data: any) => http.post('/agent/health-probes', data),
|
||||
updateHealthProbe: (id: number, data: any) => http.put(`/agent/health-probes/${id}`, data),
|
||||
deleteHealthProbe: (id: number) => http.delete(`/agent/health-probes/${id}`),
|
||||
};
|
||||
|
||||
// ==================== 区域管理 API ====================
|
||||
|
||||
Reference in new issue
Block a user