refactor(frontend): api/components/pages 目录架构统一对齐菜单域 + 日志中心修复

1. 目录架构统一(API = components = 菜单域,一域一目录 + barrel)
   - log/ 提为独立顶层域(日志中心在菜单中与监控中心平级,不再塞在 monitor/)
   - task/ 拆分原 automation.ts → cron-job / script / remote-exec
   - aiops/ 合并 ai.ts + agentCli.ts → assistant + agent-cli
   - agent/ approval/ audit/ 提升为域目录
   - auth 拆分:authApi(认证自服务) + system/user.ts(userApi+ldapApi 管理端)
   - components/automation → components/task,菜单"自动化运维"→"作业任务"
   - pages/ 按域分子目录(approval/audit/monitor),删孤儿 AuditPage.tsx
   - 所有 API 符号名不变,组件经 @/api 聚合引用,运行时行为零变化

2. Loki 查询无返回修复:aiops 容器 DNS 解析外网域名失败
   (宿主机 systemd-resolved 转发链路异常),docker-compose 显式配 dns 上游

3. ES 查询框布局:工具栏单行挤 7 控件(ES 多 150px 索引框),拆两行——
   查询输入独占一行(+查询按钮),次级控件换行 flex-wrap

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
attackerandClaude committed 2026-06-28 06:06:48 +00:00
1 parent da01cbb119
commit c0ee8e4ddd
74 files changed
+229 -216

No files matched your search

@@ -0,0 +1,77 @@
// 双线图表(网卡带宽 rx+tx)// Logan.Li
import { Network } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Brush,
} from 'recharts';
import type { AggType, BucketPoint } from './types';
interface DualLineChartProps {
title: string;
currentRx: string;
currentTx: string;
rxData: BucketPoint[];
txData: BucketPoint[];
aggType: AggType;
unit: string;
}
export function DualLineChart({
title, currentRx, currentTx, rxData, txData, aggType, unit,
}: DualLineChartProps) {
const chartData = rxData.map((d, i) => ({
time: d.time,
timestamp: d.timestamp,
rx: d[aggType] ?? 0,
tx: txData[i]?.[aggType] ?? 0,
}));
const rxGradId = `grad-rx-${title.replace(/\s/g, '')}`;
const txGradId = `grad-tx-${title.replace(/\s/g, '')}`;
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<Network className="h-4 w-4 text-blue-400" />
<span className="text-xs text-[var(--dg-text-primary)] font-medium">{title}</span>
</div>
<div className="flex items-center gap-3 text-xs">
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-blue-400 mr-1 align-middle" />接收 <b className="text-blue-300">{currentRx}</b></span>
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-emerald-400 mr-1 align-middle" />发送 <b className="text-emerald-300">{currentTx}</b></span>
</div>
</div>
<div className="h-56">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
<defs>
<linearGradient id={rxGradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id={txGradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
<XAxis dataKey="time" hide />
<YAxis hide domain={[0, 'auto']} />
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
formatter={(v: number, name: string) => [`${v.toFixed(2)} ${unit}`, name === 'rx' ? '接收' : '发送']}
labelFormatter={(label) => `时间: ${label}`} />
<Area type="monotone" dataKey="rx" stroke="#3b82f6" strokeWidth={1.5} fill={`url(#${rxGradId})`} name="rx" />
<Area type="monotone" dataKey="tx" stroke="#10b981" strokeWidth={1.5} fill={`url(#${txGradId})`} name="tx" />
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,231 @@
// 健康探针 Tab + 对话框 // Logan.Li
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/api';
import { useHealthProbes } from '@/hooks/api/useHostMonitor';
import { queryKeys } from '@/hooks/api/queryKeys';
import { Card, CardContent } from '@/components/ui/card';
import { TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
Clock, Plus, Trash2, RefreshCw, Edit, CheckCircle, XCircle,
} from 'lucide-react';
import { DEFAULT_PROBE_FORM, type ProbeForm } from './types';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
interface HealthProbeSectionProps {
selectedHost: string;
}
export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
const confirm = useConfirm();
const qc = useQueryClient();
const { data: probes = [], refetch } = useHealthProbes(selectedHost);
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
const [editingProbe, setEditingProbe] = useState<Record<string, unknown> | null>(null);
const [probeForm, setProbeForm] = useState<ProbeForm>(DEFAULT_PROBE_FORM());
const openCreateDialog = () => {
setEditingProbe(null);
setProbeForm(DEFAULT_PROBE_FORM(selectedHost));
setProbeDialogOpen(true);
};
const openEditDialog = (p: Record<string, unknown>) => {
setEditingProbe(p);
setProbeForm({
name: String(p.name || ''),
hostname: String(p.hostname || ''),
probe_type: String(p.probe_type || 'http_get'),
http_url: String(p.http_url || ''),
http_method: String(p.http_method || 'GET'),
http_expect_code: Number(p.http_expect_code) || 200,
http_expect_body: String(p.http_expect_body || ''),
tcp_host: String(p.tcp_host || ''),
tcp_port: Number(p.tcp_port) || 3306,
exec_command: String(p.exec_command || ''),
timeout: Number(p.timeout) || 10,
interval: Number(p.interval) || 60,
description: String(p.description || ''),
});
setProbeDialogOpen(true);
};
const handleSaveProbe = async () => {
if (!probeForm.name || !probeForm.hostname) return;
try {
if (editingProbe) {
await agentApi.updateHealthProbe(editingProbe.id as number, probeForm);
} else {
await agentApi.createHealthProbe(probeForm);
}
setProbeDialogOpen(false);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
} catch {
toast.error('保存失败');
}
};
const handleDeleteProbe = async (p: Record<string, unknown>) => {
const ok = await confirm({
title: '删除探针',
description: `确定删除探针「${p.name}」?`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
await agentApi.deleteHealthProbe(p.id as number);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
};
return (
<>
<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={() => refetch()}>
<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={openCreateDialog}>
<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) => (
<Card key={String(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)]">{String(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={() => openEditDialog(p)}>
<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={() => handleDeleteProbe(p)}>
<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: {String(p.http_url)}</div>
)}
{p.probe_type === 'tcp_socket' && (
<div>{String(p.tcp_host)}:{String(p.tcp_port)}</div>
)}
{p.probe_type === 'exec_command' && (
<div className="truncate font-mono">{String(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 ? String(p.last_status).toUpperCase() : '未检测'}
</span></span>
{Number(p.last_latency_ms) > 0 && <span>延迟: {String(p.last_latency_ms)}ms</span>}
{Number(p.fail_count) > 0 && <span className="text-red-400">连续失败: {String(p.fail_count)}次</span>}
{p.last_checked_at && <span className="text-[10px]">{new Date(String(p.last_checked_at)).toLocaleString('zh-CN')}</span>}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
<Dialog open={probeDialogOpen} onOpenChange={setProbeDialogOpen}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editingProbe ? '编辑探针' : '添加探针'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
{editingProbe ? '修改健康探针配置' : '创建新的健康探针,Agent 在目标主机本地执行探测'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
<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>
<DialogFooter>
<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={handleSaveProbe} disabled={!probeForm.name || !probeForm.hostname}>
{editingProbe ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,63 @@
// 指标图表卡片 // Logan.Li
import type { ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Brush,
} from 'recharts';
import type { AggType, BucketPoint } from './types';
interface MetricChartCardProps {
title: string;
value: string;
icon: ReactNode;
data: BucketPoint[];
color: string;
unit: string;
domain: [number, number | 'auto'];
aggType: AggType;
}
export function MetricChartCard({
title, value, icon, data, color, unit, domain, aggType,
}: MetricChartCardProps) {
const chartData = data.map(d => ({ ...d, value: d[aggType] }));
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">{icon}<span className="text-xs text-[var(--dg-text-primary)] font-medium truncate">{title}</span></div>
</div>
<div className="mb-2">
<span className="text-xl font-bold text-[var(--dg-text-primary)] tabular-nums">{value}</span>
{unit && <span className="text-xs text-[var(--dg-text-muted)] ml-1">{unit}</span>}
</div>
<div className="h-48">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
<defs>
<linearGradient id={`grad-${title}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
<XAxis dataKey="time" hide />
<YAxis hide domain={domain[1] === 'auto' ? [0, (max: number) => max * 1.1] : domain as [number, number]} />
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, title.replace(/\(.+\)/, '')]}
labelFormatter={(label) => `时间: ${label}`} />
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={1.5} fill={`url(#grad-${title})`} />
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,98 @@
// 端口策略对话框 // Logan.Li
import { Button } from '@/components/ui/button';
import { parsePortList } from '@/utils/validate';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
interface PortPolicyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
portPolicyMode: string;
onModeChange: (mode: string) => void;
portWhitelist: number[];
onWhitelistChange: (ports: number[]) => void;
onSave: () => void;
saving: boolean;
}
export function PortPolicyDialog({
open,
onOpenChange,
portPolicyMode,
onModeChange,
portWhitelist,
onWhitelistChange,
onSave,
saving,
}: PortPolicyDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">端口监控策略</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">配置 Agent 端口上报过滤规则</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">过滤模式</label>
<div className="flex gap-2">
<button
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'whitelist' ? 'bg-blue-500/20 border-blue-500 text-blue-400' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
onClick={() => onModeChange('whitelist')}
>
白名单 ─ 全量上报
</button>
<button
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'blacklist' ? 'bg-amber-500/20 border-amber-500 text-amber-400' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
onClick={() => onModeChange('blacklist')}
>
黑名单 ─ 仅上报选中
</button>
</div>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1.5 leading-relaxed">
{portPolicyMode === 'whitelist'
? '所有发现的端口都上报,任意端口变化都会触发告警 + 恢复通知。'
: '仅上报选中端口的变化,其余端口变化静默忽略(不告警)。系统进程端口(sshd/rpcbind/systemd等)永远忽略。'}
</p>
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">
端口列表 <span className="text-[var(--dg-text-muted)]">(逗号分隔,如: 80,443,3306,6379)</span>
{portPolicyMode === 'whitelist' && <span className="text-blue-400 ml-1">— 白名单模式无需配置</span>}
</label>
<textarea
className={`w-full h-20 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1.5 text-xs font-mono resize-none ${
portPolicyMode === 'whitelist' ? 'text-[var(--dg-text-muted)] opacity-50 cursor-not-allowed' : 'text-[var(--dg-text-primary)]'
}`}
value={portWhitelist.join(',')}
onChange={e => {
if (portPolicyMode === 'whitelist') return;
const val = e.target.value;
onWhitelistChange(parsePortList(val));
}}
placeholder={portPolicyMode === 'whitelist' ? '白名单模式下所有端口自动上报' : '80,443,3306,6379'}
readOnly={portPolicyMode === 'whitelist'}
/>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
已输入 {portWhitelist.length} 个端口
</p>
</div>
<div className="bg-amber-500/5 border border-amber-500/20 rounded p-2.5">
<p className="text-[10px] text-amber-300/80 leading-relaxed">
⚠️ 无论选择哪种模式,以下系统进程端口<b>永远不上报</b>:sshd、rpcbind、master、systemd、dbus-daemon、rsyslogd 等。Agent 采集时自动跳过。
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => onOpenChange(false)}>取消</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={onSave} disabled={saving}>
{saving ? '保存中…' : '保存策略'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,87 @@
// 系统信息 Tab 内容 // Logan.Li
import { Activity, HardDrive } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { TabsContent } from '@/components/ui/tabs';
import { parseJSON } from './utils';
import type { JsonData } from '@/types/common';
interface SystemInfoSectionProps {
selectedHost: string;
serverInfo?: JsonData;
latest?: JsonData;
heartbeatInfo?: JsonData;
portInfoCount: number;
}
export function SystemInfoSection({
selectedHost, serverInfo, latest, heartbeatInfo, portInfoCount,
}: SystemInfoSectionProps) {
const disk = latest?.disk_info ? parseJSON(latest.disk_info) : null;
const parts = disk ? (Array.isArray(disk) ? disk : (disk.partitions || [])) : [];
return (
<TabsContent value="system" className="mt-3 space-y-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-xs">
{[
['主机名', serverInfo?.hostname || selectedHost],
['IP 地址', serverInfo?.ip || '—'],
['操作系统', serverInfo?.os || '—'],
['内核版本', serverInfo?.kernel || '—'],
['CPU 核心数', serverInfo?.cpu_cores || '—'],
['总内存', serverInfo?.mem_total ? `${serverInfo.mem_total.toFixed(0)} GB` : '—'],
['已用内存', serverInfo?.mem_used ? `${serverInfo.mem_used.toFixed(1)} GB` : '—'],
['运行时长', serverInfo?.uptime ? `${Math.floor(serverInfo.uptime / 86400)}天 ${Math.floor((serverInfo.uptime % 86400) / 3600)}时` : '—'],
['Agent 版本', heartbeatInfo?.version || serverInfo?.agent_version || '—'],
['Agent 序列号', heartbeatInfo?.report_seq != null ? String(heartbeatInfo.report_seq) : '—'],
['监听端口数', portInfoCount > 0 ? `${portInfoCount}` : '—'],
['Agent RSS', heartbeatInfo?.self_rss_mb ? `${heartbeatInfo.self_rss_mb.toFixed(1)} MB` : '—'],
].map(([label, val]) => (
<div key={label as string}>
<span className="text-[var(--dg-text-muted)]">{label}:</span>
<span className="text-[var(--dg-text-primary)] font-mono">{val}</span>
</div>
))}
</div>
</CardContent>
</Card>
{latest && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">系统负载 (Load Average)</h3>
</div>
<div className="flex items-center gap-6 text-xs">
<div><span className="text-[var(--dg-text-muted)]">1min </span><span className="text-sm font-bold text-slate-200">{latest.load1 ?? '--'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">5min </span><span className="text-sm font-bold text-slate-200">{latest.load5 ?? '--'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">15min </span><span className="text-sm font-bold text-slate-200">{latest.load15 ?? '--'}</span></div>
</div>
</CardContent>
</Card>
)}
{parts.length > 0 && (
<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-2 flex items-center gap-2"><HardDrive className="h-4 w-4" />磁盘分区</h3>
<div className="space-y-1.5">
{parts.map((p: JsonData, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="text-[var(--dg-text-muted)] w-24 font-mono truncate">{p.mount || p.mountpoint || p.device || `disk${i}`}</span>
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full rounded-full bg-blue-500" style={{ width: `${Math.min(p.usage_percent || p.used_percent || 0, 100)}%` }} />
</div>
<span className="text-[var(--dg-text-primary)] w-28 text-right tabular-nums whitespace-nowrap">{(p.used_gb ?? 0).toFixed(1)}/{(p.total_gb ?? 0).toFixed(0)}GB</span>
<span className="text-blue-400 w-14 text-right tabular-nums whitespace-nowrap">{(p.usage_percent ?? p.used_percent ?? 0).toFixed(1)}%</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
);
}
@@ -0,0 +1,57 @@
// 主机监控类型定义 // Logan.Li
export const TIME_RANGES = [
{ label: '1小时', hours: 1, window: 60, buckets: 60 },
{ label: '6小时', hours: 6, window: 300, buckets: 72 },
{ label: '12小时', hours: 12, window: 600, buckets: 72 },
{ label: '1天', hours: 24, window: 1800, buckets: 96 },
{ label: '3天', hours: 72, window: 3600, buckets: 72 },
{ label: '7天', hours: 168, window: 10800, buckets: 56 },
{ label: '14天', hours: 336, window: 21600, buckets: 48 },
] as const;
export type AggType = 'avg' | 'max' | 'min';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HostRecord = Record<string, unknown>;
export interface BucketPoint {
time: string;
timestamp: number;
avg: number;
max: number;
min: number;
count: number;
}
export interface ProbeForm {
name: string;
hostname: string;
probe_type: string;
http_url: string;
http_method: string;
http_expect_code: number;
http_expect_body: string;
tcp_host: string;
tcp_port: number;
exec_command: string;
timeout: number;
interval: number;
description: string;
}
export const DEFAULT_PROBE_FORM = (hostname = ''): ProbeForm => ({
name: '',
hostname,
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: '',
});
@@ -0,0 +1,138 @@
// 主机监控工具函数 // Logan.Li
import type { BucketPoint } from './types';
import type { JsonData } from '@/types/common';
export function bucketWithStats(raw: number[], timestamps: number[], targetBuckets: number): BucketPoint[] {
if (!raw || raw.length === 0) return [];
const perBucket = Math.max(1, Math.ceil(raw.length / targetBuckets));
const result: BucketPoint[] = [];
for (let i = 0; i < raw.length; i += perBucket) {
const slice = raw.slice(i, i + perBucket);
const tsSlice = timestamps.slice(i, i + perBucket);
const sum = slice.reduce((a, b) => a + b, 0);
const avg = sum / slice.length;
const max = Math.max(...slice);
const min = Math.min(...slice);
const ts = tsSlice[Math.floor(tsSlice.length / 2)] || 0;
result.push({
time: new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
timestamp: ts,
avg: Number(avg.toFixed(2)),
max: Number(max.toFixed(2)),
min: Number(min.toFixed(2)),
count: slice.length,
});
}
return result.slice(-targetBuckets);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseJSON(raw: unknown): JsonData {
if (!raw) return {};
if (typeof raw === 'object') return raw;
try { return JSON.parse(raw as string); } catch { return {}; }
}
export function formatBPS(bps: number): string {
if (bps >= 1024 * 1024 * 1024) return `${(bps / 1024 / 1024 / 1024).toFixed(1)} GB/s`;
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
return `${bps.toFixed(1)} B/s`;
}
interface TimeConfig { buckets: number; }
export function buildMetricsFromHistory(history: JsonData[], timeConfig: TimeConfig) {
const rawCpu: number[] = []; const rawMem: number[] = []; const rawLoad1: number[] = [];
const rawDisk: number[] = []; const rawTcp: number[] = []; const rawTw: number[] = []; const rawCw: number[] = [];
const rawRx: number[] = []; const rawTx: number[] = [];
const rawIoR: number[] = []; const rawIoW: number[] = [];
const rawSwap: number[] = []; const rawFD: number[] = [];
const rawIOWait: number[] = []; const rawMemAvail: number[] = [];
const rawIoUtil: number[] = []; const rawConnTotal: number[] = [];
const rawRxErr: number[] = []; const rawTxErr: number[] = [];
const rawInode: number[] = [];
const timestamps: number[] = [];
for (const r of history) {
const ts = r.report_time ? new Date(r.report_time).getTime() / 1000 : (r.created_at ? new Date(r.created_at).getTime() / 1000 : 0);
timestamps.push(ts);
rawCpu.push(r.cpu_usage || 0);
rawIOWait.push(r.cpu_iowait || 0);
rawMem.push(r.mem_usage || 0);
rawMemAvail.push(r.mem_available || 0);
rawLoad1.push(r.load1 || 0);
rawDisk.push(r.disk_usage || 0);
const diskInfoArr = parseJSON(r.disk_info);
if (Array.isArray(diskInfoArr) && diskInfoArr.length > 0) {
const rootDisk = diskInfoArr.find((d: JsonData) => d.mount === '/') || diskInfoArr[0];
rawInode.push(rootDisk?.inode_usage_pct || 0);
} else {
rawInode.push(0);
}
const net = parseJSON(r.net_info);
rawTcp.push(net.tcp_established || 0);
rawTw.push(net.tcp_time_wait || 0);
rawCw.push(net.tcp_close_wait || 0);
rawConnTotal.push((net.tcp_established || 0) + (net.tcp_time_wait || 0) + (net.tcp_close_wait || 0));
rawRx.push(net.net_rx_bps || 0);
rawTx.push(net.net_tx_bps || 0);
rawRxErr.push(net.net_rx_errors || 0);
rawTxErr.push(net.net_tx_errors || 0);
const dio = parseJSON(r.disk_io_info);
rawIoR.push(dio.read_bps || 0);
rawIoW.push(dio.write_bps || 0);
rawIoUtil.push(dio.util || 0);
const swap = parseJSON(r.swap_info);
rawSwap.push(swap?.usage_percent || 0);
const fd = parseJSON(r.fd_info);
rawFD.push(fd?.usage_pct || 0);
}
return {
cpu: bucketWithStats(rawCpu, timestamps, timeConfig.buckets),
mem: bucketWithStats(rawMem, timestamps, timeConfig.buckets),
load1: bucketWithStats(rawLoad1, timestamps, timeConfig.buckets),
disk: bucketWithStats(rawDisk, timestamps, timeConfig.buckets),
tcpEst: bucketWithStats(rawTcp, timestamps, timeConfig.buckets),
tcpTW: bucketWithStats(rawTw, timestamps, timeConfig.buckets),
tcpCW: bucketWithStats(rawCw, timestamps, timeConfig.buckets),
rx: bucketWithStats(rawRx.map(v => v / 1024), timestamps, timeConfig.buckets),
tx: bucketWithStats(rawTx.map(v => v / 1024), timestamps, timeConfig.buckets),
ioRead: bucketWithStats(rawIoR.map(v => v / 1024), timestamps, timeConfig.buckets),
ioWrite: bucketWithStats(rawIoW.map(v => v / 1024), timestamps, timeConfig.buckets),
swap: bucketWithStats(rawSwap, timestamps, timeConfig.buckets),
fd: bucketWithStats(rawFD, timestamps, timeConfig.buckets),
iowait: bucketWithStats(rawIOWait, timestamps, timeConfig.buckets),
memAvail: bucketWithStats(rawMemAvail, timestamps, timeConfig.buckets),
ioUtil: bucketWithStats(rawIoUtil, timestamps, timeConfig.buckets),
connTotal: bucketWithStats(rawConnTotal, timestamps, timeConfig.buckets),
rxErr: bucketWithStats(rawRxErr, timestamps, timeConfig.buckets),
txErr: bucketWithStats(rawTxErr, timestamps, timeConfig.buckets),
inode: bucketWithStats(rawInode, timestamps, timeConfig.buckets),
};
}
export function getCurrentVal(latest: JsonData, netInfo: JsonData, diskIOInfo: JsonData, swapInfo: JsonData, fdInfo: JsonData,
key: string, fallback = '--',
): string {
if (!latest) return fallback;
if (key === 'cpu') return latest.cpu_usage != null ? `${latest.cpu_usage.toFixed(1)}%` : fallback;
if (key === 'mem') return latest.mem_usage != null ? `${latest.mem_usage.toFixed(1)}%` : fallback;
if (key === 'disk') return latest.disk_usage != null ? `${latest.disk_usage.toFixed(1)}%` : fallback;
if (key === 'load1') return latest.load1 != null ? String(latest.load1) : fallback;
if (key === 'tcp') return netInfo.tcp_established != null ? String(netInfo.tcp_established) : fallback;
if (key === 'tw') return netInfo.tcp_time_wait != null ? String(netInfo.tcp_time_wait) : fallback;
if (key === 'rx') return netInfo.net_rx_bps != null ? `${(netInfo.net_rx_bps / 1024).toFixed(1)} KB/s` : fallback;
if (key === 'tx') return netInfo.net_tx_bps != null ? `${(netInfo.net_tx_bps / 1024).toFixed(1)} KB/s` : fallback;
if (key === 'ioR') return diskIOInfo.read_bps != null ? `${formatBPS(diskIOInfo.read_bps)}` : fallback;
if (key === 'ioW') return diskIOInfo.write_bps != null ? `${formatBPS(diskIOInfo.write_bps)}` : fallback;
if (key === 'swap') return swapInfo?.usage_percent != null ? `${swapInfo.usage_percent.toFixed(1)}%` : fallback;
if (key === 'fd') return fdInfo?.usage_pct != null ? `${fdInfo.usage_pct.toFixed(1)}%` : fallback;
return fallback;
}