批量转换脚本引入的语法错误已回退,保留稳定的 i18n 转换: - 翻译词典: 22 namespaces, 3600+ keys - 已接入: 145+ 文件 (Dashboard/Login/K8s 主页面/Monitor/System/Deploy) - 语言切换器在 Header 中可用 (🇨🇳/🇺🇸) - tsc 零错误, Vite 构建成功
407 lines
16 KiB
TypeScript
407 lines
16 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import type { ComponentType } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { agentApi } from '@/api';
|
|
import { AgentWizard } from './AgentWizard';
|
|
import { HostDetailDialog } from './HostDetailDialog';
|
|
import { isOnline, timeAgo, asList } from './agentUtils';
|
|
import type { AgentHostRecord } from '@/types';
|
|
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 { Textarea } from '@/components/ui/textarea';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
|
|
import {
|
|
Cpu,
|
|
MemoryStick,
|
|
HardDrive,
|
|
Wifi,
|
|
WifiOff,
|
|
RefreshCw,
|
|
Terminal,
|
|
Rocket,
|
|
Activity,
|
|
Network,
|
|
Loader2,
|
|
Clock,
|
|
Server,
|
|
} from 'lucide-react';
|
|
|
|
// ============ 组件 ============
|
|
|
|
export function AgentMonitor() {
|
|
const [hosts, setHosts] = useState<AgentHostRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [selectedHost, setSelectedHost] = useState<AgentHostRecord | null>(null);
|
|
const [wizardOpen, setWizardOpen] = useState(false);
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
// 远程命令
|
|
const [cmdOpen, setCmdOpen] = useState(false);
|
|
const [cmdHost, setCmdHost] = useState('');
|
|
const [cmdCommand, setCmdCommand] = useState('');
|
|
const [cmdTimeout, setCmdTimeout] = useState(30);
|
|
const [cmdSubmitting, setCmdSubmitting] = useState(false);
|
|
const [cmdResult, setCmdResult] = useState<{ status: string; result?: string; output?: string; error?: string } | null>(null);
|
|
|
|
const loadLatest = useCallback(async (silent = false) => {
|
|
if (!silent) setRefreshing(true);
|
|
try {
|
|
const data = await agentApi.getLatest();
|
|
const list = asList(data) as AgentHostRecord[];
|
|
setHosts(list);
|
|
// 同步选中主机的最新数据
|
|
if (selectedHost) {
|
|
const fresh = list.find((h) => h.hostname === selectedHost.hostname);
|
|
if (fresh) setSelectedHost(fresh);
|
|
}
|
|
} catch (err) {
|
|
if (!silent) toast.error('加载主机列表失败');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, [selectedHost]);
|
|
|
|
useEffect(() => {
|
|
loadLatest();
|
|
timerRef.current = setInterval(() => loadLatest(true), 30000);
|
|
return () => {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// 打开远程命令弹窗
|
|
const openCommandDialog = (hostname: string) => {
|
|
setCmdHost(hostname);
|
|
setCmdCommand('');
|
|
setCmdTimeout(30);
|
|
setCmdResult(null);
|
|
setCmdOpen(true);
|
|
};
|
|
|
|
// 提交远程命令(直连 SSH 即时返回)
|
|
const submitCommand = async () => {
|
|
if (!cmdCommand.trim()) {
|
|
toast.error('请输入要执行的命令');
|
|
return;
|
|
}
|
|
setCmdSubmitting(true);
|
|
setCmdResult(null);
|
|
try {
|
|
const res = await agentApi.execCommand({
|
|
hostname: cmdHost,
|
|
command: cmdCommand.trim(),
|
|
timeout: Number(cmdTimeout) || 30,
|
|
}) as Record<string, unknown>;
|
|
const resData = (res?.data ?? {}) as Record<string, unknown>;
|
|
setCmdResult({
|
|
status: res?.code === 200 ? 'completed' : 'failed',
|
|
result: (resData.output as string) || (res?.message as string) || '无输出',
|
|
error: resData.error as string | undefined,
|
|
});
|
|
} catch (err) {
|
|
setCmdResult({ status: 'failed', result: err?.message || '执行失败' });
|
|
} finally {
|
|
setCmdSubmitting(false);
|
|
loadLatest(true);
|
|
}
|
|
};
|
|
|
|
const closeCommandDialog = () => {
|
|
setCmdOpen(false);
|
|
};
|
|
|
|
const onlineCount = hosts.filter((h) => isOnline(h.last_report || h.last_reported_at || h.updated_at)).length;
|
|
|
|
const stats = [
|
|
{ label: '主机总数', value: hosts.length, icon: Server, color: 'dg-text-blue' },
|
|
{ label: '在线', value: onlineCount, icon: Wifi, color: 'dg-text-green' },
|
|
{ label: '离线', value: hosts.length - onlineCount, icon: WifiOff, color: 'dg-text-red' },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 页头 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-[var(--dg-text-primary)]">Agent 监控</h2>
|
|
<p className="text-sm dg-text-secondary mt-0.5">实时监控 Agent 上报的主机指标</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => loadLatest()}
|
|
disabled={refreshing}
|
|
className="dg-input text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 mr-1.5 ${refreshing ? 'animate-spin' : ''}`} />
|
|
刷新
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setWizardOpen(true)}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
<Rocket className="h-4 w-4 mr-1.5" />
|
|
部署Agent
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 统计卡片 */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{stats.map((s) => (
|
|
<Card key={s.label} className="dg-card py-4">
|
|
<CardContent className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center">
|
|
<s.icon className={`h-5 w-5 ${s.color}`} />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-[var(--dg-text-primary)]">{s.value}</div>
|
|
<div className="text-xs dg-text-secondary">{s.label}</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* 主机列表 */}
|
|
{loading ? (
|
|
<Card className="dg-card">
|
|
<CardContent className="flex items-center justify-center py-16">
|
|
<Loader2 className="h-6 w-6 animate-spin dg-text-muted" />
|
|
<span className="ml-2 dg-text-secondary">加载中...</span>
|
|
</CardContent>
|
|
</Card>
|
|
) : hosts.length === 0 ? (
|
|
<Card className="dg-card">
|
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
|
<Server className="h-10 w-10 dg-text-muted mb-3" />
|
|
<p className="dg-text-secondary">暂无 Agent 上报数据</p>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setWizardOpen(true)}
|
|
className="mt-4 bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
<Rocket className="h-4 w-4 mr-1.5" />
|
|
立即部署
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
|
{hosts.map((host) => {
|
|
const online = isOnline(host.last_report || host.last_reported_at || host.updated_at);
|
|
const cpu = Number(host.cpu_percent ?? host.cpu ?? 0);
|
|
const mem = Number(host.mem_percent ?? host.mem ?? 0);
|
|
const disk = Number(host.disk_percent ?? host.disk ?? 0);
|
|
const ports: Record<string, unknown>[] = Array.isArray(host.ports) ? (host.ports as Record<string, unknown>[]) : [];
|
|
return (
|
|
<Card
|
|
key={host.hostname}
|
|
className="dg-card hover:border-[var(--dg-border)] transition-colors cursor-pointer py-4"
|
|
onClick={() => setSelectedHost(host)}
|
|
>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Server className="h-4 w-4 dg-text-muted shrink-0" />
|
|
<span className="font-medium text-[var(--dg-text-primary)] truncate">{host.hostname}</span>
|
|
</div>
|
|
{online ? (
|
|
<Badge className="bg-emerald-500/15 dg-text-green border border-emerald-500/30">
|
|
<Wifi className="h-3 w-3 mr-1" />
|
|
在线
|
|
</Badge>
|
|
) : (
|
|
<Badge className="bg-red-500/15 dg-text-red border border-red-500/30">
|
|
<WifiOff className="h-3 w-3 mr-1" />
|
|
离线
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* 指标条 */}
|
|
<div className="space-y-2">
|
|
<MetricBar icon={Cpu} label="CPU" value={cpu} />
|
|
<MetricBar icon={MemoryStick} label="内存" value={mem} />
|
|
<MetricBar icon={HardDrive} label="磁盘" value={disk} />
|
|
</div>
|
|
|
|
{/* 端口 & 时间 */}
|
|
<div className="flex items-center justify-between text-xs dg-text-secondary">
|
|
<span className="inline-flex items-center gap-1">
|
|
<Network className="h-3.5 w-3.5" />
|
|
{ports.length} 个端口
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
{timeAgo(host.last_report || host.last_reported_at || host.updated_at)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 操作 */}
|
|
<div className="flex gap-2 pt-1" onClick={(e) => e.stopPropagation()}>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="flex-1 h-8 dg-input text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]"
|
|
onClick={() => openCommandDialog(host.hostname)}
|
|
>
|
|
<Terminal className="h-3.5 w-3.5 mr-1" />
|
|
远程命令
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="flex-1 h-8 dg-input text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]"
|
|
onClick={() => setSelectedHost(host)}
|
|
>
|
|
<Activity className="h-3.5 w-3.5 mr-1" />
|
|
详情
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* 详情抽屉 */}
|
|
<HostDetailDialog
|
|
host={selectedHost}
|
|
onClose={() => setSelectedHost(null)}
|
|
onCommand={(hn) => openCommandDialog(hn)}
|
|
/>
|
|
|
|
{/* 远程命令弹窗 */}
|
|
<Dialog open={cmdOpen} onOpenChange={(v) => { if (!v) closeCommandDialog(); }}>
|
|
<DialogContent className="max-w-2xl dg-card text-[var(--dg-text-primary)]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
|
<Terminal className="h-5 w-5 dg-text-blue" />
|
|
远程命令
|
|
</DialogTitle>
|
|
<DialogDescription className="dg-text-secondary">
|
|
在 {cmdHost} 上执行命令
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[var(--dg-text-secondary)] text-xs">目标主机</Label>
|
|
<Input
|
|
value={cmdHost}
|
|
onChange={(e) => setCmdHost(e.target.value)}
|
|
readOnly
|
|
className="h-9 dg-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[var(--dg-text-secondary)] text-xs">命令</Label>
|
|
<Textarea
|
|
value={cmdCommand}
|
|
onChange={(e) => setCmdCommand(e.target.value)}
|
|
placeholder="例如:free -m && df -h"
|
|
rows={3}
|
|
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-sm resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="w-40 space-y-1.5">
|
|
<Label className="text-[var(--dg-text-secondary)] text-xs">超时(秒)</Label>
|
|
<Input
|
|
type="number"
|
|
value={cmdTimeout}
|
|
onChange={(e) => setCmdTimeout(Number(e.target.value) || 30)}
|
|
className="h-9 dg-input"
|
|
/>
|
|
</div>
|
|
|
|
{/* 结果展示 */}
|
|
{cmdResult && (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-[var(--dg-text-secondary)] text-xs">执行结果</Label>
|
|
<Badge
|
|
className={
|
|
cmdResult.status === 'success' || cmdResult.status === 'done' || cmdResult.status === 'completed'
|
|
? 'bg-emerald-500/15 dg-text-green border border-emerald-500/30'
|
|
: cmdResult.status === 'timeout'
|
|
? 'bg-amber-500/15 dg-text-yellow border border-amber-500/30'
|
|
: 'bg-red-500/15 dg-text-red border border-red-500/30'
|
|
}
|
|
>
|
|
{cmdResult.status}
|
|
</Badge>
|
|
</div>
|
|
<pre className="text-xs text-[var(--dg-text-secondary)] font-mono whitespace-pre-wrap break-all bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg p-3 max-h-48 overflow-auto">
|
|
{cmdResult.result || cmdResult.output || cmdResult.error || '无输出'}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" onClick={closeCommandDialog} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
|
关闭
|
|
</Button>
|
|
<Button
|
|
onClick={submitCommand}
|
|
disabled={cmdSubmitting || !cmdCommand.trim()}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{cmdSubmitting ? (
|
|
<><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />执行中</>
|
|
) : (
|
|
<><Terminal className="h-4 w-4 mr-1.5" />执行</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 部署向导 */}
|
|
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============ 指标条子组件 ============
|
|
function MetricBar({ icon: Icon, label, value }: { icon: ComponentType<{ className?: string }>; label: string; value: number }) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="h-3.5 w-3.5 dg-text-muted shrink-0" />
|
|
<span className="text-xs dg-text-secondary w-8 shrink-0">{label}</span>
|
|
<div className="flex-1">
|
|
<Progress value={value} className={`h-1.5 ${value >= 90 ? '[&>[data-slot=progress-indicator]]:bg-red-500' : value >= 75 ? '[&>[data-slot=progress-indicator]]:bg-amber-500' : '[&>[data-slot=progress-indicator]]:bg-emerald-500'}`} />
|
|
</div>
|
|
<span className={`text-xs font-medium w-10 text-right ${value >= 90 ? 'dg-text-red' : value >= 75 ? 'dg-text-yellow' : 'text-[var(--dg-text-secondary)]'}`}>
|
|
{value.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AgentMonitor;
|