feat: 脚本/定时任务真实执行+输出捕获+执行历史 — 替换pending空壳为本地执行,采集stdout/stderr,更新统计;修复CronJobManager/Automation字段名不匹配(status→enabled,cron→cron_expr,command→content,language→type)

This commit is contained in:
attacker committed 2026-06-14 22:11:18 +08:00
1 parent 85bc2a8e05
commit a4fd5fc178
19 files changed
+2715 -114

No files matched your search

@@ -0,0 +1,542 @@
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { agentApi } from '@/services/api';
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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertTriangle,
CheckCircle,
Plus,
Pencil,
Trash2,
Loader2,
RefreshCw,
Bell,
Cpu,
MemoryStick,
HardDrive,
WifiOff,
Network,
Clock,
} from 'lucide-react';
// ============ 常量 ============
const METRIC_OPTIONS = [
{ value: 'cpu', label: 'CPU', icon: Cpu },
{ value: 'mem', label: '内存', icon: MemoryStick },
{ value: 'disk', label: '磁盘', icon: HardDrive },
{ value: 'offline', label: '主机离线', icon: WifiOff },
{ value: 'port_down', label: '端口不通', icon: Network },
];
const OPERATOR_OPTIONS = [
{ value: '>', label: '> 大于' },
{ value: '<', label: '< 小于' },
{ value: '>=', label: '≥ 大于等于' },
{ value: '<=', label: '≤ 小于等于' },
{ value: '==', label: '= 等于' },
];
const SEVERITY_OPTIONS = [
{ value: 'critical', label: '严重', color: 'bg-red-500/15 text-red-400 border-red-500/30' },
{ value: 'warning', label: '警告', color: 'bg-amber-500/15 text-amber-400 border-amber-500/30' },
{ value: 'info', label: '提示', color: 'bg-blue-500/15 text-blue-400 border-blue-500/30' },
];
function getMetricLabel(metric: string) {
return METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric;
}
function asList(data: any): any[] {
if (!data) return [];
if (Array.isArray(data)) return data;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.items)) return data.items;
if (Array.isArray(data.data)) return data.data;
return [];
}
// ============ 主组件 ============
export function AgentAlerts() {
const confirm = useConfirm();
const [activeTab, setActiveTab] = useState('events');
const [alerts, setAlerts] = useState<any[]>([]);
const [rules, setRules] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedView, setResolvedView] = useState(false);
// 规则编辑
const [ruleOpen, setRuleOpen] = useState(false);
const [editingRule, setEditingRule] = useState<any>(null);
const [ruleSaving, setRuleSaving] = useState(false);
const [ruleForm, setRuleForm] = useState({
name: '',
metric: 'cpu',
operator: '>',
threshold: '90',
port: '',
severity: 'warning',
enabled: true,
duration: '60',
});
const loadData = useCallback(async () => {
setLoading(true);
try {
const [alertsRes, rulesRes] = await Promise.all([
agentApi.listAlerts({ resolved: String(resolvedView) }),
agentApi.listRules(),
]);
setAlerts(asList(alertsRes));
setRules(asList(rulesRes));
} catch (err) {
console.error(err);
toast.error('加载告警数据失败');
} finally {
setLoading(false);
}
}, [resolvedView]);
useEffect(() => {
loadData();
}, [loadData]);
// ============ 告警事件 ============
const handleResolve = async (id: number) => {
const ok = await confirm({ title: '恢复告警', description: '确定将此告警标记为已恢复?', confirmText: '恢复' });
if (!ok) return;
try {
await agentApi.resolveAlert(id);
toast.success('告警已恢复');
loadData();
} catch (err: any) {
toast.error(err?.message || '操作失败');
}
};
// ============ 规则 CRUD ============
const resetRuleForm = () => {
setRuleForm({ name: '', metric: 'cpu', operator: '>', threshold: '90', port: '', severity: 'warning', enabled: true, duration: '60' });
setEditingRule(null);
};
const openCreateRule = () => {
resetRuleForm();
setRuleOpen(true);
};
const openEditRule = (rule: any) => {
setEditingRule(rule);
setRuleForm({
name: rule.name || '',
metric: rule.metric || 'cpu',
operator: rule.operator || '>',
threshold: String(rule.threshold ?? '90'),
port: rule.port ? String(rule.port) : '',
severity: rule.severity || 'warning',
enabled: rule.enabled !== false,
duration: rule.duration ? String(rule.duration) : '60',
});
setRuleOpen(true);
};
const handleSaveRule = async () => {
if (!ruleForm.name.trim()) {
toast.error('请输入规则名称');
return;
}
setRuleSaving(true);
try {
const payload: any = {
name: ruleForm.name.trim(),
metric: ruleForm.metric,
operator: ruleForm.operator,
threshold: Number(ruleForm.threshold) || 0,
severity: ruleForm.severity,
enabled: ruleForm.enabled,
duration: Number(ruleForm.duration) || 0,
};
if (ruleForm.metric === 'port_down' && ruleForm.port) {
payload.port = Number(ruleForm.port);
}
if (editingRule) {
await agentApi.updateRule(editingRule.id, payload);
toast.success('规则已更新');
} else {
await agentApi.createRule(payload);
toast.success('规则已创建');
}
setRuleOpen(false);
resetRuleForm();
loadData();
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setRuleSaving(false);
}
};
const handleDeleteRule = async (id: number) => {
const ok = await confirm({ title: '删除规则', description: '确定删除此告警规则?', confirmText: '删除', variant: 'destructive' });
if (!ok) return;
try {
await agentApi.deleteRule(id);
toast.success('规则已删除');
loadData();
} catch (err: any) {
toast.error(err?.message || '删除失败');
}
};
const handleToggleRule = async (rule: any) => {
try {
await agentApi.updateRule(rule.id, { ...rule, enabled: !rule.enabled });
loadData();
} catch (err: any) {
toast.error(err?.message || '操作失败');
}
};
const severityBadge = (sev: string) => {
const opt = SEVERITY_OPTIONS.find((s) => s.value === sev) || SEVERITY_OPTIONS[1];
return (
<Badge className={opt.color}>
<AlertTriangle className="h-3 w-3 mr-1" />
{opt.label}
</Badge>
);
};
return (
<div className="space-y-4">
{/* 页头 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-100">Agent 告警</h2>
<p className="text-sm text-slate-400 mt-0.5">基于 Agent 上报指标的告警规则与事件</p>
</div>
<Button variant="outline" size="sm" onClick={loadData} disabled={loading} className="bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700">
<RefreshCw className={`h-4 w-4 mr-1.5 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">告警事件</TabsTrigger>
<TabsTrigger value="rules" className="data-[state=active]:bg-slate-700">告警规则</TabsTrigger>
</TabsList>
{/* ========== 告警事件 ========== */}
<TabsContent value="events" className="mt-4 space-y-3">
<div className="flex items-center gap-2">
<Button
size="sm"
variant={resolvedView ? 'outline' : 'default'}
onClick={() => setResolvedView(false)}
className={!resolvedView ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}
>
未恢复
</Button>
<Button
size="sm"
variant={resolvedView ? 'default' : 'outline'}
onClick={() => setResolvedView(true)}
className={resolvedView ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}
>
已恢复
</Button>
</div>
{loading ? (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-slate-500" />
<span className="ml-2 text-slate-400">加载中...</span>
</CardContent>
</Card>
) : alerts.length === 0 ? (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="flex flex-col items-center justify-center py-16">
<CheckCircle className="h-10 w-10 text-emerald-500 mb-3" />
<p className="text-slate-400">{resolvedView ? '暂无已恢复的告警' : '当前无活动告警,一切正常'}</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{alerts.map((alert) => (
<Card key={alert.id} className="bg-slate-900 border-slate-800 py-4">
<CardContent>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-lg bg-slate-800 flex items-center justify-center shrink-0">
<AlertTriangle className={`h-4 w-4 ${(alert.severity || 'warning') === 'critical' ? 'text-red-400' : (alert.severity || 'warning') === 'warning' ? 'text-amber-400' : 'text-blue-400'}`} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{severityBadge(alert.severity)}
<span className="font-medium text-slate-100">{alert.rule_name || alert.name || alert.title || '告警'}</span>
</div>
<p className="text-sm text-slate-400 mt-1">{alert.message || alert.description || '触发告警阈值'}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500 flex-wrap">
<span className="inline-flex items-center gap-1">
<Cpu className="h-3 w-3" />
{alert.hostname || '--'}
</span>
<span className="inline-flex items-center gap-1">
<span className="text-slate-400">指标:</span> {getMetricLabel(alert.metric)}
</span>
{alert.value != null && (
<span className="inline-flex items-center gap-1">
<span className="text-slate-400">当前值:</span> {alert.value}
</span>
)}
<span className="inline-flex items-center gap-1">
<Clock className="h-3 w-3" />
{alert.created_at || alert.triggered_at ? new Date(alert.created_at || alert.triggered_at).toLocaleString() : '--'}
</span>
</div>
</div>
</div>
{!resolvedView && (
<Button
size="sm"
variant="outline"
onClick={() => handleResolve(alert.id)}
className="shrink-0 h-8 bg-slate-800 border-slate-700 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-400"
>
<CheckCircle className="h-3.5 w-3.5 mr-1" />
恢复
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
{/* ========== 告警规则 ========== */}
<TabsContent value="rules" className="mt-4 space-y-3">
<div className="flex justify-end">
<Button size="sm" onClick={openCreateRule} className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-1.5" />
新建规则
</Button>
</div>
{loading ? (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-slate-500" />
<span className="ml-2 text-slate-400">加载中...</span>
</CardContent>
</Card>
) : rules.length === 0 ? (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="flex flex-col items-center justify-center py-16">
<Bell className="h-10 w-10 text-slate-600 mb-3" />
<p className="text-slate-400">暂无告警规则</p>
<Button size="sm" onClick={openCreateRule} className="mt-4 bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-1.5" />
创建第一条规则
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{rules.map((rule) => {
const MetricIcon = METRIC_OPTIONS.find((m) => m.value === rule.metric)?.icon || Cpu;
return (
<Card key={rule.id} className={`bg-slate-900 ${rule.enabled === false ? 'border-slate-800 opacity-60' : 'border-slate-800'} py-4`}>
<CardContent>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-lg bg-slate-800 flex items-center justify-center shrink-0">
<MetricIcon className="h-4 w-4 text-blue-400" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-slate-100">{rule.name}</span>
{severityBadge(rule.severity)}
{rule.enabled === false && (
<Badge className="bg-slate-700 text-slate-400 border border-slate-600">已禁用</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 flex-wrap">
<span>{getMetricLabel(rule.metric)}</span>
{rule.metric !== 'offline' && (
<span className="text-slate-400">
{rule.operator} {rule.threshold}{rule.metric === 'port_down' ? '' : '%'}
</span>
)}
{rule.metric === 'port_down' && rule.port ? (
<span className="text-slate-400">端口 {rule.port}</span>
) : null}
{rule.duration ? <span className="text-slate-400">持续 {rule.duration}s</span> : null}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch checked={rule.enabled !== false} onCheckedChange={() => handleToggleRule(rule)} />
<Button size="icon" variant="ghost" className="h-8 w-8 text-slate-400 hover:text-slate-100 hover:bg-slate-800" onClick={() => openEditRule(rule)}>
<Pencil className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800" onClick={() => handleDeleteRule(rule.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</TabsContent>
</Tabs>
{/* ========== 规则编辑弹窗 ========== */}
<Dialog open={ruleOpen} onOpenChange={(v) => { if (!v) setRuleOpen(false); }}>
<DialogContent className="max-w-md bg-slate-900 border-slate-800 text-slate-100">
<DialogHeader>
<DialogTitle className="text-slate-100">{editingRule ? '编辑规则' : '新建规则'}</DialogTitle>
<DialogDescription className="text-slate-400">配置 Agent 指标告警规则</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">规则名称</Label>
<Input
value={ruleForm.name}
onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })}
placeholder="例如:CPU 使用率告警"
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">监控指标</Label>
<Select value={ruleForm.metric} onValueChange={(v) => setRuleForm({ ...ruleForm, metric: v })}>
<SelectTrigger className="h-9 bg-slate-800 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
{METRIC_OPTIONS.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">严重程度</Label>
<Select value={ruleForm.severity} onValueChange={(v) => setRuleForm({ ...ruleForm, severity: v })}>
<SelectTrigger className="h-9 bg-slate-800 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
{SEVERITY_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{ruleForm.metric !== 'offline' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">比较运算符</Label>
<Select value={ruleForm.operator} onValueChange={(v) => setRuleForm({ ...ruleForm, operator: v })}>
<SelectTrigger className="h-9 bg-slate-800 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
{OPERATOR_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">阈值 {ruleForm.metric !== 'port_down' ? '(%)' : ''}</Label>
<Input
type="number"
value={ruleForm.threshold}
onChange={(e) => setRuleForm({ ...ruleForm, threshold: e.target.value })}
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
/>
</div>
</div>
)}
{ruleForm.metric === 'port_down' && (
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">监控端口</Label>
<Input
type="number"
value={ruleForm.port}
onChange={(e) => setRuleForm({ ...ruleForm, port: e.target.value })}
placeholder="例如:8080"
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">持续时间(秒)</Label>
<Input
type="number"
value={ruleForm.duration}
onChange={(e) => setRuleForm({ ...ruleForm, duration: e.target.value })}
placeholder="指标持续超限多久后告警"
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-slate-300 text-xs">启用规则</Label>
<Switch checked={ruleForm.enabled} onCheckedChange={(v) => setRuleForm({ ...ruleForm, enabled: v })} />
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setRuleOpen(false)} className="border-slate-700 text-slate-300">取消</Button>
<Button onClick={handleSaveRule} disabled={ruleSaving} className="bg-blue-600 hover:bg-blue-700 text-white">
{ruleSaving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : null}
{editingRule ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AgentAlerts;
@@ -0,0 +1,671 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import { agentApi } from '@/services/api';
import { AgentWizard } from './AgentWizard';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Cpu,
MemoryStick,
HardDrive,
Wifi,
WifiOff,
RefreshCw,
Terminal,
Rocket,
Activity,
Network,
Loader2,
Clock,
Server,
} from 'lucide-react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
// ============ 工具函数 ============
/** 在线判定:3 分钟内有上报视为在线 */
function isOnline(lastReport: any): boolean {
if (!lastReport) return false;
const t = new Date(lastReport).getTime();
if (Number.isNaN(t)) return false;
return Date.now() - t < 3 * 60 * 1000;
}
function timeAgo(ts: any): string {
if (!ts) return '--';
const t = new Date(ts).getTime();
if (Number.isNaN(t)) return '--';
const diff = Math.floor((Date.now() - t) / 1000);
if (diff < 60) return `${diff}秒前`;
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
return new Date(ts).toLocaleString();
}
function asList(data: any): any[] {
if (!data) return [];
if (Array.isArray(data)) return data;
if (Array.isArray(data.list)) return data.list;
if (Array.isArray(data.items)) return data.items;
if (Array.isArray(data.data)) return data.data;
return [];
}
// ============ 组件 ============
export function AgentMonitor() {
const [hosts, setHosts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedHost, setSelectedHost] = useState<any>(null);
const [wizardOpen, setWizardOpen] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 远程命令
const [cmdOpen, setCmdOpen] = useState(false);
const [cmdHost, setCmdHost] = useState('');
const [cmdType, setCmdType] = useState('command');
const [cmdCommand, setCmdCommand] = useState('');
const [cmdTimeout, setCmdTimeout] = useState('30');
const [cmdSubmitting, setCmdSubmitting] = useState(false);
const [cmdResult, setCmdResult] = useState<any>(null);
const [cmdPolling, setCmdPolling] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadLatest = useCallback(async (silent = false) => {
if (!silent) setRefreshing(true);
try {
const data = await agentApi.getLatest();
const list = asList(data);
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);
setCmdType('command');
setCmdCommand('');
setCmdTimeout('30');
setCmdResult(null);
setCmdOpen(true);
};
// 提交远程命令并轮询结果
const submitCommand = async () => {
if (!cmdCommand.trim()) {
toast.error('请输入要执行的命令');
return;
}
setCmdSubmitting(true);
setCmdResult(null);
try {
const res: any = await agentApi.createTask({
hostname: cmdHost,
type: cmdType,
command: cmdCommand.trim(),
timeout: Number(cmdTimeout) || 30,
});
const taskId = res?.id ?? res?.task_id;
toast.success('任务已下发');
setCmdSubmitting(false);
// 轮询任务结果
if (taskId != null) {
setCmdPolling(true);
let attempts = 0;
pollTimerRef.current = setInterval(async () => {
attempts++;
try {
const tasks: any = await agentApi.listTasks({ hostname: cmdHost });
const list = asList(tasks);
const t = list.find((x: any) => String(x.id) === String(taskId));
if (t && t.status && t.status !== 'pending' && t.status !== 'running') {
setCmdResult(t);
setCmdPolling(false);
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
loadLatest(true);
}
} catch {
/* ignore */
}
if (attempts > 15) {
// 30s 超时
setCmdPolling(false);
setCmdResult({ status: 'timeout', result: '等待结果超时,请稍后在任务列表查看' });
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
}
}, 2000);
}
} catch (err: any) {
toast.error(err?.message || '任务下发失败');
setCmdSubmitting(false);
}
};
const closeCommandDialog = () => {
setCmdOpen(false);
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
setCmdPolling(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: 'text-blue-400' },
{ label: '在线', value: onlineCount, icon: Wifi, color: 'text-emerald-400' },
{ label: '离线', value: hosts.length - onlineCount, icon: WifiOff, color: 'text-red-400' },
];
return (
<div className="space-y-4">
{/* 页头 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-100">Agent 监控</h2>
<p className="text-sm text-slate-400 mt-0.5">实时监控 Agent 上报的主机指标</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => loadLatest()}
disabled={refreshing}
className="bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700 hover:text-slate-100"
>
<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="bg-slate-900 border-slate-800 py-4">
<CardContent className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center">
<s.icon className={`h-5 w-5 ${s.color}`} />
</div>
<div>
<div className="text-2xl font-bold text-slate-100">{s.value}</div>
<div className="text-xs text-slate-400">{s.label}</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* 主机列表 */}
{loading ? (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
<span className="ml-2 text-slate-400">加载中...</span>
</CardContent>
</Card>
) : hosts.length === 0 ? (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="flex flex-col items-center justify-center py-16">
<Server className="h-10 w-10 text-slate-600 mb-3" />
<p className="text-slate-400">暂无 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: any[] = Array.isArray(host.ports) ? host.ports : [];
return (
<Card
key={host.hostname}
className="bg-slate-900 border-slate-800 hover:border-slate-700 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 text-slate-500 shrink-0" />
<span className="font-medium text-slate-100 truncate">{host.hostname}</span>
</div>
{online ? (
<Badge className="bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
<Wifi className="h-3 w-3 mr-1" />
在线
</Badge>
) : (
<Badge className="bg-red-500/15 text-red-400 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 text-slate-400">
<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 bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700"
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 bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700"
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 bg-slate-900 border-slate-800 text-slate-100">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Terminal className="h-5 w-5 text-blue-400" />
远程命令
</DialogTitle>
<DialogDescription className="text-slate-400">
在 {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-slate-300 text-xs">目标主机</Label>
<Input
value={cmdHost}
onChange={(e) => setCmdHost(e.target.value)}
readOnly
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">类型</Label>
<Select value={cmdType} onValueChange={setCmdType}>
<SelectTrigger className="h-9 bg-slate-800 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
<SelectItem value="command">Shell 命令</SelectItem>
<SelectItem value="script">多行脚本</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">命令</Label>
<Textarea
value={cmdCommand}
onChange={(e) => setCmdCommand(e.target.value)}
placeholder="例如:free -m && df -h"
rows={3}
className="bg-slate-950 border-slate-700 text-slate-200 font-mono text-sm resize-none"
/>
</div>
<div className="w-40 space-y-1.5">
<Label className="text-slate-300 text-xs">超时(秒)</Label>
<Input
type="number"
value={cmdTimeout}
onChange={(e) => setCmdTimeout(e.target.value)}
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
/>
</div>
{/* 结果展示 */}
{cmdResult && (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Label className="text-slate-300 text-xs">执行结果</Label>
<Badge
className={
cmdResult.status === 'success' || cmdResult.status === 'done' || cmdResult.status === 'completed'
? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30'
: cmdResult.status === 'timeout'
? 'bg-amber-500/15 text-amber-400 border border-amber-500/30'
: 'bg-red-500/15 text-red-400 border border-red-500/30'
}
>
{cmdResult.status}
</Badge>
</div>
<pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all bg-slate-950 border border-slate-800 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-slate-700 text-slate-300">
关闭
</Button>
<Button
onClick={submitCommand}
disabled={cmdSubmitting || cmdPolling || !cmdCommand.trim()}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{cmdSubmitting ? (
<><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />下发中</>
) : cmdPolling ? (
<><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: any; label: string; value: number }) {
return (
<div className="flex items-center gap-2">
<Icon className="h-3.5 w-3.5 text-slate-500 shrink-0" />
<span className="text-xs text-slate-400 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 ? 'text-red-400' : value >= 75 ? 'text-amber-400' : 'text-slate-300'}`}>
{value.toFixed(1)}%
</span>
</div>
);
}
// ============ 主机详情弹窗 ============
function HostDetailDialog({
host,
onClose,
onCommand,
}: {
host: any;
onClose: () => void;
onCommand: (hostname: string) => void;
}) {
const [history, setHistory] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [hours, setHours] = useState(1);
useEffect(() => {
if (!host) return;
let active = true;
setLoading(true);
agentApi
.getHistory(host.hostname, hours)
.then((data) => {
if (active) setHistory(asList(data));
})
.catch(() => { if (active) setHistory([]); })
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [host, hours]);
if (!host) return null;
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: any[] = Array.isArray(host.ports) ? host.ports : [];
const chartData = history.map((h) => ({
time: new Date(h.timestamp || h.time || h.created_at || Date.now()).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
cpu: Number(h.cpu_percent ?? h.cpu ?? 0),
mem: Number(h.mem_percent ?? h.mem ?? 0),
disk: Number(h.disk_percent ?? h.disk ?? 0),
}));
return (
<Dialog open={!!host} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-3xl bg-slate-900 border-slate-800 text-slate-100">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Server className="h-5 w-5 text-blue-400" />
{host.hostname}
{online ? (
<Badge className="bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">在线</Badge>
) : (
<Badge className="bg-red-500/15 text-red-400 border border-red-500/30">离线</Badge>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 基本信息 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<InfoCell label="IP 地址" value={host.ip || '--'} />
<InfoCell label="操作系统" value={host.os || host.platform || '--'} />
<InfoCell label="最后上报" value={timeAgo(host.last_report || host.last_reported_at || host.updated_at)} />
<InfoCell label="端口数" value={String(ports.length)} />
</div>
{/* 趋势图 */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-200 flex items-center gap-1.5">
<Activity className="h-4 w-4 text-blue-400" />
指标趋势
</span>
<Select value={String(hours)} onValueChange={(v) => setHours(Number(v))}>
<SelectTrigger size="sm" className="h-7 w-28 bg-slate-800 border-slate-700 text-slate-300 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
<SelectItem value="1">最近 1 小时</SelectItem>
<SelectItem value="6">最近 6 小时</SelectItem>
<SelectItem value="24">最近 24 小时</SelectItem>
</SelectContent>
</Select>
</div>
<div className="h-56 rounded-lg bg-slate-950 border border-slate-800 p-3">
{loading ? (
<div className="h-full flex items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-slate-500" />
</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-sm text-slate-500">暂无历史数据</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
<defs>
<linearGradient id="gCpu" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id="gMem" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="gDisk" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.4} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="time" stroke="#475569" fontSize={11} />
<YAxis stroke="#475569" fontSize={11} domain={[0, 100]} />
<Tooltip
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '6px', color: '#f8fafc', fontSize: '12px' }}
/>
<Area type="monotone" dataKey="cpu" stroke="#3b82f6" fill="url(#gCpu)" name="CPU" />
<Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#gMem)" name="内存" />
<Area type="monotone" dataKey="disk" stroke="#f59e0b" fill="url(#gDisk)" name="磁盘" />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* 端口列表 */}
<div>
<span className="text-sm font-medium text-slate-200 flex items-center gap-1.5 mb-2">
<Network className="h-4 w-4 text-blue-400" />
端口发现 ({ports.length})
</span>
{ports.length === 0 ? (
<p className="text-xs text-slate-500">暂无端口数据</p>
) : (
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
{ports.map((p, idx) => (
<Badge key={idx} className="bg-slate-800 text-slate-300 border border-slate-700">
<Network className="h-3 w-3 mr-1" />
{p.port ?? p}
</Badge>
))}
</div>
)}
</div>
</div>
<DialogFooter className="gap-2">
<Button
onClick={() => onCommand(host.hostname)}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Terminal className="h-4 w-4 mr-1.5" />
远程命令
</Button>
<Button variant="outline" onClick={onClose} className="border-slate-700 text-slate-300">
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function InfoCell({ label, value }: { label: string; value: string }) {
return (
<div className="bg-slate-950 border border-slate-800 rounded-lg px-3 py-2">
<div className="text-xs text-slate-500">{label}</div>
<div className="text-sm text-slate-200 font-medium truncate">{value}</div>
</div>
);
}
export default AgentMonitor;
@@ -0,0 +1,173 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useUserStore } from '@/store';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Copy, Check, Terminal, Download, Settings, Play, Cpu } from 'lucide-react';
interface AgentWizardProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** 自动检测服务器地址:使用当前浏览器访问的主机名,端口固定 8080 */
function detectServerAddress(): string {
if (typeof window !== 'undefined' && window.location) {
const host = window.location.hostname;
return host ? `http://${host}:8080` : 'http://127.0.0.1:8080';
}
return 'http://127.0.0.1:8080';
}
export function AgentWizard({ open, onOpenChange }: AgentWizardProps) {
const token = useUserStore((s) => s.token) || '<YOUR_TOKEN>';
const serverUrl = detectServerAddress();
const [copied, setCopied] = useState(false);
const installCommand = `curl -fsSL ${serverUrl.replace(/^http:/, 'http:')}/agent/install.sh | bash -s -- --server ${serverUrl} --token ${token}`;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(installCommand);
setCopied(true);
toast.success('安装命令已复制');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('复制失败,请手动复制');
}
};
const binaryUrl = `${serverUrl}/agent/aiops-agent-linux-amd64`;
const manualSteps = [
{
icon: Download,
title: '1. 下载二进制',
commands: [
`curl -fsSL ${binaryUrl} -o /usr/local/bin/aiops-agent`,
],
},
{
icon: Settings,
title: '2. 赋予执行权限',
commands: ['chmod +x /usr/local/bin/aiops-agent'],
},
{
icon: Cpu,
title: '3. 创建 systemd 服务',
commands: [
`cat > /etc/systemd/system/aiops-agent.service << 'EOF'`,
`[Unit]`,
`Description=AIOps Agent`,
`After=network.target`,
``,
`[Service]`,
`Type=simple`,
`ExecStart=/usr/local/bin/aiops-agent --server ${serverUrl} --token ${token}`,
`Restart=always`,
`RestartSec=5`,
``,
`[Install]`,
`WantedBy=multi-user.target`,
`EOF`,
],
},
{
icon: Play,
title: '4. 启动并设置开机自启',
commands: [
'systemctl daemon-reload',
'systemctl enable --now aiops-agent',
'systemctl status aiops-agent',
],
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100">
<DialogHeader>
<DialogTitle className="text-slate-100">部署 Agent</DialogTitle>
<DialogDescription className="text-slate-400">
在目标主机上执行以下命令即可完成 Agent 安装与注册
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="auto" className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="auto" className="data-[state=active]:bg-slate-700">
<Terminal className="h-4 w-4 mr-1.5" />
安装Agent
</TabsTrigger>
<TabsTrigger value="manual" className="data-[state=active]:bg-slate-700">
手动安装
</TabsTrigger>
</TabsList>
{/* 自动安装 */}
<TabsContent value="auto" className="mt-4 space-y-3">
<p className="text-sm text-slate-400">
在目标主机执行以下命令,Agent 将自动下载、安装并启动:
</p>
<div className="relative rounded-lg bg-slate-950 border border-slate-800 p-4">
<pre className="text-xs text-emerald-400 font-mono whitespace-pre-wrap break-all pr-10">
{installCommand}
</pre>
<Button
size="icon"
variant="ghost"
onClick={handleCopy}
className="absolute top-2 right-2 h-8 w-8 text-slate-400 hover:text-slate-100 hover:bg-slate-800"
title="复制"
>
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<div className="flex items-start gap-2 text-xs text-slate-500">
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
服务器地址已自动检测为当前访问地址({serverUrl})。Agent 安装成功后将自动连接并开始上报指标。
</span>
</div>
</TabsContent>
{/* 手动安装 */}
<TabsContent value="manual" className="mt-4 space-y-4">
<div className="max-h-[420px] overflow-y-auto space-y-3 pr-1">
{manualSteps.map((step) => (
<div key={step.title} className="rounded-lg bg-slate-950 border border-slate-800 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-800/50">
<step.icon className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">{step.title}</span>
</div>
<div className="p-3 space-y-1">
{step.commands.map((cmd, idx) => (
<div key={idx} className="flex items-start gap-2">
<span className="text-slate-600 font-mono text-xs select-none mt-0.5">$</span>
<code className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all flex-1">
{cmd}
</code>
</div>
))}
</div>
</div>
))}
</div>
<p className="text-xs text-slate-500">
完成上述步骤后,Agent 将作为 systemd 服务运行并自动上报指标。
</p>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
export default AgentWizard;
@@ -44,6 +44,7 @@ import {
Loader2,
} from 'lucide-react';
import { cronJobApi, scriptApi } from '@/services/api';
import { toast } from 'sonner';
function parseParams(params: any) {
if (Array.isArray(params)) return params;
@@ -99,11 +100,11 @@ export function AutomationManager() {
(script.description || '').toLowerCase().includes(searchQuery.toLowerCase())
);
const getJobStatusBadge = (status: any) => {
return status === 1 ? (
const getJobStatusBadge = (enabled: any) => {
return enabled ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<Play className="h-3 w-3 mr-1" />
运行中
已启用
</Badge>
) : (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
@@ -115,23 +116,32 @@ export function AutomationManager() {
const getLanguageBadge = (language: any) => {
const colors: Record<string, string> = {
shell: 'border-green-500/30 text-green-400 bg-green-500/10',
bash: 'border-green-500/30 text-green-400 bg-green-500/10',
python: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
powershell: 'border-cyan-500/30 text-cyan-400 bg-cyan-500/10',
yaml: 'border-purple-500/30 text-purple-400 bg-purple-500/10',
};
return (
<Badge variant="outline" className={colors[language] || colors.bash}>
{(language || '').toUpperCase()}
<Badge variant="outline" className={colors[language] || colors.shell}>
{(language || 'shell').toUpperCase()}
</Badge>
);
};
const handleExecuteScript = async (id: any) => {
try {
await scriptApi.executeScript(id);
const result = await scriptApi.executeScript(id);
const status = result?.status || 'success';
if (status === 'success') {
toast.success('执行成功', { description: `耗时 ${result?.duration || 0}ms` });
} else {
toast.error('执行失败', { description: (result?.output || '').slice(0, 100) });
}
setIsExecuteDialogOpen(false);
} catch (error) {
console.error('Failed to execute script:', error);
toast.error('执行失败');
}
};
@@ -176,7 +186,7 @@ export function AutomationManager() {
<div>
<p className="text-sm text-slate-500">运行中</p>
<p className="text-2xl font-bold text-emerald-400">
{cronJobs.filter((j) => j.status === 1).length}
{cronJobs.filter((j) => j.enabled).length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
@@ -279,21 +289,19 @@ export function AutomationManager() {
<p className="text-xs text-slate-500 mt-0.5">{job.description}</p>
</div>
</TableCell>
<TableCell>{getJobStatusBadge(job.status)}</TableCell>
<TableCell>{getJobStatusBadge(job.enabled)}</TableCell>
<TableCell>
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
{job.cron}
{job.cron_expr}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
{job.last_run_at ? new Date(job.last_run_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{job.next_run ? new Date(job.next_run).toLocaleString() : '--'}
</span>
<span className="text-sm text-slate-400">--</span>
</TableCell>
<TableCell>
<div className="text-sm">
@@ -308,7 +316,19 @@ export function AutomationManager() {
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
onClick={() => cronJobApi.executeJob(job.id)}
onClick={async () => {
try {
const result = await cronJobApi.executeJob(job.id);
if (result?.status === 'success') {
toast.success('执行成功', { description: `耗时 ${result?.duration || 0}ms` });
} else {
toast.error('执行失败');
}
loadData();
} catch {
toast.error('执行失败');
}
}}
>
<Play className="h-4 w-4" />
</Button>
@@ -342,7 +362,7 @@ export function AutomationManager() {
</div>
</div>
<div className="flex items-center gap-1">
{getLanguageBadge(script.language)}
{getLanguageBadge(script.type)}
</div>
</div>
</CardHeader>
@@ -423,7 +443,7 @@ export function AutomationManager() {
<div className="space-y-4">
<div className="flex items-center gap-2">
{selectedScript && getLanguageBadge(selectedScript.language)}
{selectedScript && getLanguageBadge(selectedScript.type)}
<span className="text-sm text-slate-500">
更新于 {selectedScript && selectedScript.updated_at ? new Date(selectedScript.updated_at).toLocaleDateString() : '--'}
</span>
@@ -1,9 +1,8 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
@@ -35,21 +34,16 @@ import {
Search,
Clock,
Pause,
RotateCw,
Edit,
Trash2,
History,
CheckCircle,
XCircle,
Loader2,
Server,
Calendar,
Terminal,
MoreHorizontal,
Eye,
Copy,
} from 'lucide-react';
import { cronJobApi } from '@/services/api';
import { toast } from 'sonner';
import { cronJobApi, taskExecutionApi } from '@/services/api';
export function CronJobManager() {
const [searchQuery, setSearchQuery] = useState('');
@@ -57,13 +51,16 @@ export function CronJobManager() {
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isLogDialogOpen, setIsLogDialogOpen] = useState(false);
const [selectedJob, setSelectedJob] = useState<any | null>(null);
const [executions, setExecutions] = useState<any[]>([]);
const [executionsLoading, setExecutionsLoading] = useState(false);
const [expandedExec, setExpandedExec] = useState<number | null>(null);
const [executingId, setExecutingId] = useState<number | null>(null);
const [newJob, setNewJob] = useState({
name: '',
description: '',
cron: '',
command: '',
targetServers: '',
cron_expr: '',
type: 'command',
content: '',
});
const loadData = async () => {
@@ -82,6 +79,23 @@ export function CronJobManager() {
loadData();
}, []);
const loadExecutions = async () => {
try {
setExecutionsLoading(true);
const data = await taskExecutionApi.getExecutions({ task_type: 'cron', page_size: 50 });
setExecutions(data.list || []);
} catch (error) {
console.error('Failed to load executions:', error);
} finally {
setExecutionsLoading(false);
}
};
const openLogDialog = () => {
setIsLogDialogOpen(true);
loadExecutions();
};
const filteredJobs = jobs.filter(
(job) =>
(job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -132,18 +146,25 @@ export function CronJobManager() {
};
const handleCreateJob = async () => {
if (!newJob.name || !newJob.cron_expr) {
toast.error('请填写任务名称和 Cron 表达式');
return;
}
try {
await cronJobApi.createJob({
name: newJob.name,
description: newJob.description,
cron: newJob.cron,
command: newJob.command,
cron_expr: newJob.cron_expr,
type: newJob.type,
content: newJob.content,
});
toast.success('任务创建成功');
setIsCreateDialogOpen(false);
setNewJob({ name: '', description: '', cron: '', command: '', targetServers: '' });
setNewJob({ name: '', description: '', cron_expr: '', type: 'command', content: '' });
loadData();
} catch (error) {
console.error('Failed to create cron job:', error);
toast.error('创建失败');
}
};
@@ -153,24 +174,37 @@ export function CronJobManager() {
loadData();
} catch (error) {
console.error('Failed to toggle job:', error);
toast.error('操作失败');
}
};
const handleExecuteJob = async (id: any) => {
setExecutingId(id);
try {
await cronJobApi.executeJob(id);
const result = await cronJobApi.executeJob(id);
const status = result?.status || 'success';
if (status === 'success') {
toast.success('执行成功', { description: `耗时 ${result?.duration || 0}ms` });
} else {
toast.error('执行失败', { description: (result?.output || '').slice(0, 100) });
}
loadData();
} catch (error) {
console.error('Failed to execute job:', error);
toast.error('执行失败');
} finally {
setExecutingId(null);
}
};
const handleDeleteJob = async (id: any) => {
try {
await cronJobApi.deleteJob(id);
toast.success('已删除');
loadData();
} catch (error) {
console.error('Failed to delete job:', error);
toast.error('删除失败');
}
};
@@ -183,7 +217,7 @@ export function CronJobManager() {
<p className="text-sm text-slate-500 mt-1">Cron 任务管理与执行</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsLogDialogOpen(true)}>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={openLogDialog}>
<History className="h-4 w-4 mr-2" />
执行历史
</Button>
@@ -206,14 +240,16 @@ export function CronJobManager() {
<CardContent className="p-4">
<p className="text-slate-400 text-sm">已启用</p>
<p className="text-2xl font-bold text-emerald-400">
{jobs.filter((j) => j.status === 1).length}
{jobs.filter((j) => j.enabled).length}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">执行中</p>
<p className="text-2xl font-bold text-blue-400">--</p>
<p className="text-slate-400 text-sm">累计成功</p>
<p className="text-2xl font-bold text-emerald-400">
{jobs.reduce((sum, j) => sum + (j.success_count || 0), 0)}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
@@ -251,47 +287,59 @@ export function CronJobManager() {
<Card key={job.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="font-medium text-slate-100">{job.name}</h3>
<Badge
variant="outline"
className={
job.status === 1
job.enabled
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}
>
{job.status === 1 ? '已启用' : '已禁用'}
{job.enabled ? '已启用' : '已禁用'}
</Badge>
{getStatusBadge(job.last_status)}
<Badge variant="outline" className="border-slate-600 text-slate-400">
{job.type === 'script' ? '脚本' : job.type === 'playbook' ? 'Playbook' : '命令'}
</Badge>
</div>
<p className="text-sm text-slate-400 mt-1">{job.description}</p>
<div className="flex items-center gap-4 mt-2 text-sm">
<span className="flex items-center gap-1 text-slate-400">
<Clock className="h-3 w-3" />
{parseCron(job.cron)}
</span>
<span className="flex items-center gap-1 text-slate-400">
<Terminal className="h-3 w-3" />
<code className="text-xs bg-slate-900 px-2 py-0.5 rounded">{job.command}</code>
{parseCron(job.cron_expr)}
</span>
{(job.content || job.script) && (
<span className="flex items-center gap-1 text-slate-400">
<Terminal className="h-3 w-3" />
<code className="text-xs bg-slate-900 px-2 py-0.5 rounded truncate max-w-xs">
{job.script ? job.script.name : job.content}
</code>
</span>
)}
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>上次执行: {job.last_run ? new Date(job.last_run).toLocaleString() : '--'}</span>
<span>下次执行: {job.next_run ? new Date(job.next_run).toLocaleString() : '--'}</span>
<span>上次执行: {job.last_run_at ? new Date(job.last_run_at).toLocaleString() : '--'}</span>
<span>成功: {job.success_count || 0} / 失败: {job.fail_count || 0}</span>
<span>累计: {job.run_count || 0} 次</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20"
disabled={executingId === job.id}
onClick={() => handleExecuteJob(job.id)}
>
<Play className="h-4 w-4 mr-1" />
{executingId === job.id ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Play className="h-4 w-4 mr-1" />
)}
立即执行
</Button>
<Button
@@ -300,13 +348,9 @@ export function CronJobManager() {
className="h-8 w-8 text-slate-400 hover:text-blue-400"
onClick={() => handleToggleJob(job.id)}
>
{job.status === 1 ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{job.enabled ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit className="h-4 w-4" />
</Button>
<Button
@@ -354,12 +398,12 @@ export function CronJobManager() {
<Label className="text-slate-300">Cron 表达式</Label>
<div className="flex gap-2">
<Input
value={newJob.cron}
onChange={(e) => setNewJob({ ...newJob, cron: e.target.value })}
value={newJob.cron_expr}
onChange={(e) => setNewJob({ ...newJob, cron_expr: e.target.value })}
placeholder="0 2 * * *"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
<Select onValueChange={(v) => setNewJob({ ...newJob, cron: v })}>
<Select onValueChange={(v) => setNewJob({ ...newJob, cron_expr: v })}>
<SelectTrigger className="w-48 bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue placeholder="快速选择" />
</SelectTrigger>
@@ -377,21 +421,12 @@ export function CronJobManager() {
<div>
<Label className="text-slate-300">执行命令</Label>
<Textarea
value={newJob.command}
onChange={(e) => setNewJob({ ...newJob, command: e.target.value })}
value={newJob.content}
onChange={(e) => setNewJob({ ...newJob, content: e.target.value })}
placeholder="/scripts/backup.sh"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1 font-mono"
/>
</div>
<div>
<Label className="text-slate-300">目标服务器</Label>
<Input
value={newJob.targetServers}
onChange={(e) => setNewJob({ ...newJob, targetServers: e.target.value })}
placeholder="server1,server2 或 all"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} className="border-slate-700 text-slate-300">
@@ -404,49 +439,62 @@ export function CronJobManager() {
</DialogContent>
</Dialog>
{/* Execution Log Dialog */}
{/* Execution History Dialog */}
<Dialog open={isLogDialogOpen} onOpenChange={setIsLogDialogOpen}>
<DialogContent className="max-w-4xl bg-slate-900 border-slate-700 text-slate-100">
<DialogContent className="max-w-4xl bg-slate-900 border-slate-700 text-slate-100 max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>执行历史</DialogTitle>
<DialogDescription className="text-slate-500">定时任务执行记录</DialogDescription>
<DialogDescription className="text-slate-500">定时任务执行记录与输出日志</DialogDescription>
</DialogHeader>
<div className="mt-4">
{jobs.length === 0 ? (
{executionsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : executions.length === 0 ? (
<div className="text-center text-slate-500 py-8">暂无执行记录</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-700">
<TableHead className="text-slate-400">任务</TableHead>
<TableHead className="text-slate-400">上次执行</TableHead>
<TableHead className="text-slate-400">成功次数</TableHead>
<TableHead className="text-slate-400">失败次数</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">耗时</TableHead>
<TableHead className="text-slate-400">执行时间</TableHead>
<TableHead className="text-slate-400">输出</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => (
<TableRow key={job.id} className="border-slate-700">
<TableCell className="text-slate-200">{job.name}</TableCell>
<TableCell className="text-slate-400">
{job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
</TableCell>
<TableCell className="text-emerald-400">{job.success_count || 0}</TableCell>
<TableCell className="text-red-400">{job.fail_count || 0}</TableCell>
<TableCell>
<Badge
variant="outline"
className={
(job.fail_count || 0) > 0
? 'border-red-500/30 text-red-400 bg-red-500/10'
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
}
>
{(job.fail_count || 0) > 0 ? '有失败' : '正常'}
</Badge>
</TableCell>
</TableRow>
{executions.map((exec) => (
<>
<TableRow key={exec.id} className="border-slate-700 cursor-pointer hover:bg-slate-800/50" onClick={() => setExpandedExec(expandedExec === exec.id ? null : exec.id)}>
<TableCell className="text-slate-200">{exec.task_name}</TableCell>
<TableCell>{getStatusBadge(exec.status)}</TableCell>
<TableCell className="text-slate-400">{exec.duration ? `${exec.duration}ms` : '--'}</TableCell>
<TableCell className="text-slate-400">
{exec.started_at ? new Date(exec.started_at).toLocaleString() : '--'}
</TableCell>
<TableCell className="text-slate-400">
{exec.output ? (
<span className="text-blue-400 text-xs flex items-center gap-1">
<Terminal className="h-3 w-3" />
{expandedExec === exec.id ? '收起' : '查看'}
</span>
) : (
'--'
)}
</TableCell>
</TableRow>
{expandedExec === exec.id && exec.output && (
<TableRow key={`${exec.id}-out`} className="border-slate-700">
<TableCell colSpan={5} className="bg-slate-950 p-0">
<pre className="p-4 text-xs text-slate-300 font-mono whitespace-pre-wrap max-h-64 overflow-y-auto">
{exec.output}
</pre>
</TableCell>
</TableRow>
)}
</>
))}
</TableBody>
</Table>
@@ -61,6 +61,7 @@ const menuItems: MenuItem[] = [
{ id: 'regions', name: '区域管理', icon: MapPin, path: '/regions' },
{ id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms' },
{ id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing' },
{ id: 'agent-monitor', name: 'Agent监控', icon: Activity, path: '/agent-monitor' },
],
},
{
@@ -93,6 +94,7 @@ const menuItems: MenuItem[] = [
{ id: 'logs-monitor', name: '日志能力', icon: FileCode, path: '/logs-monitor' },
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/grafana' },
{ id: 'agent-alerts', name: 'Agent告警', icon: AlertTriangle, path: '/agent-alerts' },
],
},
{ id: 'approval', name: '审批流程', icon: CheckSquare, path: '/approval' },