feat: 脚本库全栈CRUD管理器(ScriptManager) — 替换占位AutomationManager

- 新增 ScriptManager.tsx: 完整脚本库管理(新建/编辑/查看/复制/删除/执行)
  - 卡片列表 + 搜索 + 类型筛选(shell/python/powershell/yaml)
  - 新建/编辑复用对话框,类型切换自动填充模板
  - 执行: ConfirmProvider确认 + 本地执行 + toast反馈(耗时/输出)
  - 执行记录tab: 真实TaskExecution记录 + 终端风格日志查看
  - 4统计卡片(总数/执行中/今日成功/今日失败)
- App.tsx: /scripts路由指向ScriptManager, 移除无用AutomationManager及dead /playbooks路由
- 删除 Automation.tsx (占位显示页,非功能性按钮)
This commit is contained in:
attacker committed 2026-06-15 00:09:29 +08:00
1 parent b7c4d90b6b
commit 242389a1de
3 files changed
+660 -556

No files matched your search

@@ -1,553 +0,0 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
PlayCircle,
Plus,
Search,
Clock,
Play,
Pause,
RotateCw,
FileCode,
History,
Eye,
Edit,
Copy,
Download,
Loader2,
} from 'lucide-react';
import { cronJobApi, scriptApi } from '@/services/api';
import { toast } from 'sonner';
function parseParams(params: any) {
if (Array.isArray(params)) return params;
if (typeof params === 'string') {
try {
const parsed = JSON.parse(params);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
}
export function AutomationManager() {
const [activeTab, setActiveTab] = useState('cronjobs');
const [searchQuery, setSearchQuery] = useState('');
const [cronJobs, setCronJobs] = useState<any[]>([]);
const [scripts, setScripts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedScript, setSelectedScript] = useState<any | null>(null);
const [isExecuteDialogOpen, setIsExecuteDialogOpen] = useState(false);
const loadData = async () => {
try {
setLoading(true);
const [jobsData, scriptsData] = await Promise.all([
cronJobApi.getJobs(),
scriptApi.getScripts(),
]);
setCronJobs(jobsData.list || []);
setScripts(scriptsData.list || []);
} catch (error) {
console.error('Failed to load automation data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredJobs = cronJobs.filter(
(job) =>
(job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(job.description || '').toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredScripts = scripts.filter(
(script) =>
(script.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(script.description || '').toLowerCase().includes(searchQuery.toLowerCase())
);
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">
<Pause className="h-3 w-3 mr-1" />
已暂停
</Badge>
);
};
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.shell}>
{(language || 'shell').toUpperCase()}
</Badge>
);
};
const handleExecuteScript = async (id: any) => {
try {
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('执行失败');
}
};
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">自动化运维</h1>
<p className="text-sm text-slate-500 mt-1">定时任务与脚本管理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<History className="h-4 w-4 mr-2" />
执行历史
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建任务
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">定时任务</p>
<p className="text-2xl font-bold text-slate-100">{cronJobs.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Clock className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">运行中</p>
<p className="text-2xl font-bold text-emerald-400">
{cronJobs.filter((j) => j.enabled).length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Play className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">脚本数量</p>
<p className="text-2xl font-bold text-slate-100">{scripts.length}</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
<FileCode className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">累计执行</p>
<p className="text-2xl font-bold text-blue-400">
{cronJobs.reduce((sum, j) => sum + (j.success_count || 0) + (j.fail_count || 0), 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
<RotateCw className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索任务或脚本..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : null}
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="cronjobs" className="data-[state=active]:bg-slate-700">
<Clock className="h-4 w-4 mr-2" />
定时任务
</TabsTrigger>
<TabsTrigger value="scripts" className="data-[state=active]:bg-slate-700">
<FileCode className="h-4 w-4 mr-2" />
脚本库
</TabsTrigger>
<TabsTrigger value="playbooks" className="data-[state=active]:bg-slate-700">
<PlayCircle className="h-4 w-4 mr-2" />
任务编排
</TabsTrigger>
</TabsList>
<TabsContent value="cronjobs" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">任务名称</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">Cron表达式</TableHead>
<TableHead className="text-slate-400">上次执行</TableHead>
<TableHead className="text-slate-400">下次执行</TableHead>
<TableHead className="text-slate-400">执行统计</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredJobs.map((job) => (
<TableRow key={job.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium text-slate-200">{job.name}</span>
</div>
<p className="text-xs text-slate-500 mt-0.5">{job.description}</p>
</div>
</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_expr}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{job.last_run_at ? new Date(job.last_run_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">--</span>
</TableCell>
<TableCell>
<div className="text-sm">
<span className="text-emerald-400">{job.success_count || 0}</span>
<span className="text-slate-500 mx-1">/</span>
<span className="text-red-400">{job.fail_count || 0}</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
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>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="scripts" className="mt-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredScripts.map((script) => (
<Card key={script.id} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
<FileCode className="h-5 w-5 text-purple-400" />
</div>
<div>
<CardTitle className="text-base text-slate-100">{script.name}</CardTitle>
<p className="text-slate-500 text-sm">{script.description}</p>
</div>
</div>
<div className="flex items-center gap-1">
{getLanguageBadge(script.type)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="bg-slate-950 rounded-lg p-3 font-mono text-xs text-slate-400 overflow-x-auto">
<pre className="whitespace-pre-wrap">{(script.content || '').slice(0, 200)}...</pre>
</div>
<div className="flex items-center justify-between mt-4">
<div className="flex gap-2">
{parseParams(script.params).map((param: any) => (
<Badge key={param.name} variant="outline" className="border-slate-700 text-slate-400 text-xs">
{param.name}
{param.required && <span className="text-red-400 ml-0.5">*</span>}
</Badge>
))}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedScript(script)}
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-blue-400 hover:text-blue-400"
onClick={() => {
setSelectedScript(script);
setIsExecuteDialogOpen(true);
}}
>
<Play className="h-4 w-4 mr-1" />
执行
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="playbooks" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8">
<div className="text-center">
<PlayCircle className="h-12 w-12 mx-auto text-slate-500 mb-4" />
<h3 className="text-lg font-medium text-slate-300 mb-2">任务编排</h3>
<p className="text-sm text-slate-500 mb-4">可视化编排复杂运维任务流程</p>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
创建编排
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Script Detail Dialog */}
<Dialog open={!!selectedScript && !isExecuteDialogOpen} onOpenChange={() => setSelectedScript(null)}>
<DialogContent className="max-w-3xl bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<FileCode className="h-5 w-5 text-purple-400" />
{selectedScript?.name}
</DialogTitle>
<DialogDescription className="text-slate-500">
{selectedScript?.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-2">
{selectedScript && getLanguageBadge(selectedScript.type)}
<span className="text-sm text-slate-500">
更新于 {selectedScript && selectedScript.updated_at ? new Date(selectedScript.updated_at).toLocaleDateString() : '--'}
</span>
</div>
<div className="bg-slate-950 rounded-lg p-4 font-mono text-sm text-slate-300 overflow-x-auto max-h-80">
<pre className="whitespace-pre">{selectedScript?.content}</pre>
</div>
{selectedScript && parseParams(selectedScript.params).length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">参数</h4>
<div className="space-y-2">
{parseParams(selectedScript.params).map((param: any) => (
<div key={param.name} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
<code className="text-sm text-slate-300">{param.name}</code>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
{param.type}
</Badge>
{param.required && (
<Badge variant="outline" className="border-red-700 text-red-400 text-xs">
必填
</Badge>
)}
<span className="text-sm text-slate-500">{param.description}</span>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Copy className="h-4 w-4 mr-2" />
复制
</Button>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Download className="h-4 w-4 mr-2" />
下载
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => setIsExecuteDialogOpen(true)}
>
<Play className="h-4 w-4 mr-2" />
执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Execute Script Dialog */}
<Dialog open={isExecuteDialogOpen} onOpenChange={setIsExecuteDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100">执行脚本</DialogTitle>
<DialogDescription className="text-slate-500">
{selectedScript?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-slate-300">目标服务器</label>
<Select>
<SelectTrigger className="mt-1 bg-slate-800 border-slate-700 text-slate-200">
<SelectValue placeholder="选择服务器" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部服务器</SelectItem>
<SelectItem value="web">Web服务器组</SelectItem>
<SelectItem value="app">应用服务器组</SelectItem>
<SelectItem value="db">数据库服务器组</SelectItem>
</SelectContent>
</Select>
</div>
{selectedScript && parseParams(selectedScript.params).map((param: any) => (
<div key={param.name}>
<label className="text-sm font-medium text-slate-300">
{param.name}
{param.required && <span className="text-red-400">*</span>}
</label>
<Input
placeholder={param.description}
defaultValue={param.default}
className="mt-1 bg-slate-800 border-slate-700 text-slate-200"
/>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsExecuteDialogOpen(false)}>
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => selectedScript && handleExecuteScript(selectedScript.id)}>
<Play className="h-4 w-4 mr-2" />
开始执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,658 @@
import React, { useState, useEffect } from 'react';
import {
Play, FileCode, Clock, CheckCircle, XCircle, RotateCcw, Eye,
Plus, Search, Trash2, Edit, Copy, Terminal, Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { scriptApi, taskExecutionApi } from '@/services/api';
// ---- 类型定义 ----
interface Script {
id: number;
name: string;
code: string;
description: string;
type: string; // shell | python | powershell | yaml
content: string;
timeout: number;
version: number;
run_count: number;
created_at: string;
updated_at: string;
}
interface ExecutionRecord {
id: number;
task_type: string;
task_id: number;
task_name: string;
status: string;
output: string;
duration: number;
started_at: string | null;
finished_at: string | null;
created_at: string;
}
// 将后端返回的脚本原始对象映射为前端结构
function mapScript(raw: any): Script {
return {
id: raw.id,
name: raw.name,
code: raw.code || '',
description: raw.description || '',
type: raw.type || 'shell',
content: raw.content || '',
timeout: raw.timeout || 300,
version: raw.version || 1,
run_count: raw.run_count || 0,
created_at: raw.created_at,
updated_at: raw.updated_at,
};
}
const scriptTypes = [
{ value: 'shell', label: 'Shell', sample: '#!/bin/bash\n# 系统巡检脚本\necho "===== 系统信息 ====="\nuname -a\necho ""\necho "===== 磁盘使用 ====="\ndf -h\necho ""\necho "===== 内存使用 ====="\nfree -h\n' },
{ value: 'python', label: 'Python', sample: '#!/usr/bin/env python3\n# 主机信息采集\nimport platform, socket\nprint("主机名:", socket.gethostname())\nprint("系统:", platform.platform())\nprint("Python:", platform.python_version())\n' },
{ value: 'powershell', label: 'PowerShell', sample: '# Windows 系统检查\nWrite-Host "===== 系统信息 ====="\nGet-ComputerInfo | Select-Object CsName, WindowsVersion\nWrite-Host "===== 磁盘使用 ====="\nGet-PSDrive -PSProvider FileSystem | Format-Table\n' },
{ value: 'yaml', label: 'YAML', sample: '# YAML 配置/任务定义\nname: example-task\nsteps:\n - name: step1\n action: echo\n message: hello\n' },
];
const typeColors: Record<string, string> = {
shell: 'border-green-500/30 text-green-500 bg-green-500/10',
bash: 'border-green-500/30 text-green-500 bg-green-500/10',
python: 'border-blue-500/30 text-blue-500 bg-blue-500/10',
powershell: 'border-cyan-500/30 text-cyan-500 bg-cyan-500/10',
yaml: 'border-purple-500/30 text-purple-500 bg-purple-500/10',
};
const getTypeBadge = (type: string) => (
<Badge variant="outline" className={typeColors[type] || typeColors.shell}>
{(type || 'shell').toUpperCase()}
</Badge>
);
// 生成唯一脚本编码
const genCode = () => `sc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
export function ScriptManager() {
const _confirm = useConfirm();
const [scripts, setScripts] = useState<Script[]>([]);
const [executions, setExecutions] = useState<ExecutionRecord[]>([]);
const [loading, setLoading] = useState(false);
const [executing, setExecuting] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [activeTab, setActiveTab] = useState('scripts');
// 弹窗状态
const [editorOpen, setEditorOpen] = useState(false); // 新建/编辑
const [detailOpen, setDetailOpen] = useState(false); // 查看
const [logsOpen, setLogsOpen] = useState(false); // 执行日志
const [editingId, setEditingId] = useState<number | null>(null);
const [selectedScript, setSelectedScript] = useState<Script | null>(null);
const [selectedExec, setSelectedExec] = useState<ExecutionRecord | null>(null);
// 表单状态
const [formData, setFormData] = useState({
name: '', description: '', content: '', type: 'shell', timeout: 300,
});
const fetchScripts = async () => {
setLoading(true);
try {
const res = await scriptApi.getScripts({ page_size: 100 });
const items = (res as any)?.list || [];
setScripts(items.map(mapScript));
} catch (error) {
toast.error('获取脚本列表失败');
} finally {
setLoading(false);
}
};
const fetchExecutions = async () => {
try {
const res = await taskExecutionApi.getExecutions({ task_type: 'script', page_size: 50 });
const items = (res as any)?.list || [];
setExecutions(items);
} catch (error) {
// 静默失败
}
};
useEffect(() => {
fetchScripts();
fetchExecutions();
}, []);
// ---- 新建/编辑 ----
const openCreate = () => {
setEditingId(null);
setFormData({
name: '', description: '', content: scriptTypes[0].sample,
type: 'shell', timeout: 300,
});
setEditorOpen(true);
};
const openEdit = (sc: Script) => {
setEditingId(sc.id);
setFormData({
name: sc.name,
description: sc.description,
content: sc.content,
type: sc.type,
timeout: sc.timeout,
});
setEditorOpen(true);
};
const handleTypeChange = (t: string) => {
// 新建状态下切换类型时若内容仍为默认模板则同步替换,避免用户手动清空
setFormData((prev) => {
const isDefaultSample = scriptTypes.some(s => s.sample === prev.content) || prev.content === '';
const match = scriptTypes.find(s => s.value === t);
return {
...prev,
type: t,
content: editingId === null && isDefaultSample && match ? match.sample : prev.content,
};
});
};
const handleSave = async () => {
if (!formData.name.trim() || !formData.content.trim()) {
toast.error('请填写名称和脚本内容');
return;
}
const payload = {
name: formData.name.trim(),
code: genCode(),
description: formData.description,
type: formData.type,
content: formData.content,
timeout: Number(formData.timeout) || 300,
};
try {
if (editingId !== null) {
// 更新时后端忽略 code 字段(按主键更新)
await scriptApi.updateScript(editingId, payload);
toast.success('脚本更新成功');
} else {
await scriptApi.createScript(payload);
toast.success('脚本创建成功');
}
setEditorOpen(false);
fetchScripts();
} catch (error) {
toast.error(editingId !== null ? '更新失败' : '创建失败');
}
};
// ---- 复制 ----
const handleClone = async (sc: Script) => {
try {
await scriptApi.createScript({
name: `${sc.name} (副本)`,
code: genCode(),
description: sc.description,
type: sc.type,
content: sc.content,
timeout: sc.timeout,
});
toast.success('复制成功');
fetchScripts();
} catch (error) {
toast.error('复制失败');
}
};
// ---- 删除 ----
const handleDelete = async (sc: Script) => {
const ok = await _confirm({
title: '删除脚本',
description: `确定删除「${sc.name}」吗?此操作不可恢复。`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
try {
await scriptApi.deleteScript(sc.id);
toast.success('删除成功');
fetchScripts();
} catch (error) {
toast.error('删除失败');
}
};
// ---- 执行 ----
const handleExecute = async (sc: Script) => {
const ok = await _confirm({
title: '执行脚本',
description: `将在 AIOps 主机上本地执行「${sc.name}」(${sc.type})。`,
confirmText: '开始执行',
});
if (!ok) return;
setExecuting(true);
try {
const res = await scriptApi.executeScript(sc.id);
const exec = (res as any)?.data ?? res;
if (exec?.status === 'success') {
toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`);
} else {
toast.error(`执行失败,耗时 ${exec?.duration ?? 0}ms`, {
description: ((exec?.output || '') as string).slice(0, 120),
});
}
fetchScripts();
fetchExecutions();
} catch (error) {
toast.error('执行请求失败');
} finally {
setExecuting(false);
}
};
// ---- 辅助 ----
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />等待中</Badge>;
case 'running':
return <Badge variant="default" className="bg-blue-500 gap-1"><Loader2 className="w-3 h-3 animate-spin" />执行中</Badge>;
case 'success':
return <Badge variant="default" className="bg-green-600 gap-1"><CheckCircle className="w-3 h-3" />成功</Badge>;
case 'failed':
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />失败</Badge>;
default:
return <Badge variant="secondary">{status || '未知'}</Badge>;
}
};
const formatDate = (s: string | null) => {
if (!s) return '-';
const d = new Date(s);
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('zh-CN', { hour12: false });
};
const filteredScripts = scripts.filter(sc => {
const q = searchQuery.toLowerCase();
const matchSearch = sc.name.toLowerCase().includes(q) ||
sc.description.toLowerCase().includes(q) ||
sc.code.toLowerCase().includes(q);
const matchType = typeFilter === 'all' || sc.type === typeFilter;
return matchSearch && matchType;
});
const runningCount = executions.filter(e => e.status === 'running').length;
const todaySuccess = executions.filter(e =>
e.status === 'success' && new Date(e.created_at).toDateString() === new Date().toDateString()
).length;
const todayFailed = executions.filter(e =>
e.status === 'failed' && new Date(e.created_at).toDateString() === new Date().toDateString()
).length;
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">脚本总数</p>
<p className="text-2xl font-bold">{scripts.length}</p>
</div>
<div className="p-3 bg-purple-500/10 rounded-lg">
<FileCode className="w-5 h-5 text-purple-500" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">执行中</p>
<p className="text-2xl font-bold text-blue-600">{runningCount}</p>
</div>
<div className="p-3 bg-blue-500/10 rounded-lg">
<Loader2 className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">今日成功</p>
<p className="text-2xl font-bold text-green-600">{todaySuccess}</p>
</div>
<div className="p-3 bg-green-500/10 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">今日失败</p>
<p className="text-2xl font-bold text-red-600">{todayFailed}</p>
</div>
<div className="p-3 bg-red-500/10 rounded-lg">
<XCircle className="w-5 h-5 text-red-600" />
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}>
<TabsList className="grid w-full grid-cols-2 lg:w-[360px]">
<TabsTrigger value="scripts">脚本库</TabsTrigger>
<TabsTrigger value="jobs">执行记录</TabsTrigger>
</TabsList>
{/* ============ 脚本库 ============ */}
<TabsContent value="scripts" className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索名称、编码、描述..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部类型</SelectItem>
{scriptTypes.map(t => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchScripts}>
<RotateCcw className="w-4 h-4 mr-2" />
刷新
</Button>
<Button onClick={openCreate}>
<Plus className="w-4 h-4 mr-2" />
新建脚本
</Button>
</div>
</div>
{/* 脚本卡片列表 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{loading ? (
<div className="col-span-full text-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin inline mr-2" />
加载中...
</div>
) : filteredScripts.length === 0 ? (
<div className="col-span-full text-center py-8 text-muted-foreground">
{scripts.length === 0 ? '暂无脚本,点击「新建脚本」创建第一个' : '未匹配到符合条件的脚本'}
</div>
) : (
filteredScripts.map((sc) => (
<Card key={sc.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<FileCode className="w-5 h-5 text-purple-500 shrink-0" />
<CardTitle className="text-base truncate">{sc.name}</CardTitle>
</div>
{getTypeBadge(sc.type)}
</div>
<CardDescription className="line-clamp-2">{sc.description || '暂无描述'}</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="bg-muted/40 rounded-md p-2 mb-3 font-mono text-xs text-muted-foreground overflow-hidden">
<pre className="whitespace-pre-wrap line-clamp-3">{sc.content}</pre>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
<span>执行 {sc.run_count} 次 · v{sc.version}</span>
<span>超时 {sc.timeout}s</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" title="查看内容"
onClick={() => { setSelectedScript(sc); setDetailOpen(true); }}>
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" title="执行"
disabled={executing}
onClick={() => handleExecute(sc)}>
{executing
? <Loader2 className="w-4 h-4 animate-spin text-blue-600" />
: <Play className="w-4 h-4 text-green-600" />}
</Button>
<Button variant="ghost" size="sm" title="编辑" onClick={() => openEdit(sc)}>
<Edit className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" title="复制" onClick={() => handleClone(sc)}>
<Copy className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" title="删除" onClick={() => handleDelete(sc)}>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
</CardContent>
</Card>
))
)}
</div>
</TabsContent>
{/* ============ 执行记录 ============ */}
<TabsContent value="jobs" className="space-y-4">
<div className="flex justify-end">
<Button variant="outline" onClick={fetchExecutions}>
<RotateCcw className="w-4 h-4 mr-2" />
刷新
</Button>
</div>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">#</th>
<th className="px-4 py-3 text-left text-sm font-medium">脚本</th>
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
<th className="px-4 py-3 text-left text-sm font-medium">开始时间</th>
<th className="px-4 py-3 text-left text-sm font-medium">耗时</th>
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y">
{executions.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
暂无执行记录
</td>
</tr>
) : (
executions.map((job) => (
<tr key={job.id} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm text-muted-foreground">{job.id}</td>
<td className="px-4 py-3 text-sm">{job.task_name}</td>
<td className="px-4 py-3">{getStatusBadge(job.status)}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{formatDate(job.started_at)}</td>
<td className="px-4 py-3 text-sm">
{job.duration ? `${(job.duration / 1000).toFixed(1)}s` : '-'}
</td>
<td className="px-4 py-3">
<Button variant="ghost" size="sm" title="查看日志"
onClick={() => { setSelectedExec(job); setLogsOpen(true); }}>
<Terminal className="w-4 h-4" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* ===== 新建/编辑脚本对话框 ===== */}
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>{editingId !== null ? '编辑脚本' : '新建脚本'}</DialogTitle>
<DialogDescription>编写运维脚本,支持 Shell / Python / PowerShell</DialogDescription>
</DialogHeader>
<div className="space-y-4 overflow-y-auto max-h-[60vh] pr-1">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>名称</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="如:系统巡检脚本"
/>
</div>
<div>
<Label>类型</Label>
<Select value={formData.type} onValueChange={handleTypeChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{scriptTypes.map(t => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>描述</Label>
<Input
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="脚本用途说明"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>超时(秒)</Label>
<Input
type="number"
value={formData.timeout}
onChange={(e) => setFormData({ ...formData, timeout: Number(e.target.value) })}
placeholder="300"
/>
</div>
{editingId !== null && (
<div>
<Label>编码</Label>
<Input value={selectedScript?.code || ''} disabled className="font-mono text-xs" />
</div>
)}
</div>
<div>
<Label>脚本内容</Label>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
className="min-h-[360px] font-mono text-sm"
placeholder="在此编写脚本内容..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditorOpen(false)}>取消</Button>
<Button onClick={handleSave}>
<CheckCircle className="w-4 h-4 mr-2" />
{editingId !== null ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ===== 查看脚本内容 ===== */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileCode className="w-5 h-5 text-purple-500" />
{selectedScript?.name}
{selectedScript && getTypeBadge(selectedScript.type)}
</DialogTitle>
<DialogDescription>{selectedScript?.description}</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[500px] w-full rounded-md border bg-black">
<pre className="p-4 font-mono text-sm text-green-400 whitespace-pre-wrap">
{selectedScript?.content || '(空)'}
</pre>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => setDetailOpen(false)}>关闭</Button>
{selectedScript && (
<Button onClick={() => { setDetailOpen(false); openEdit(selectedScript); }}>
<Edit className="w-4 h-4 mr-2" />
编辑
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* ===== 执行日志 ===== */}
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>执行日志</DialogTitle>
<DialogDescription>
{selectedExec?.task_name} · {getStatusBadge(selectedExec?.status || '')}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[480px] w-full rounded-md border bg-black">
<pre className="p-4 font-mono text-sm text-green-400 whitespace-pre-wrap">
{selectedExec?.output || '暂无日志输出...'}
</pre>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => setLogsOpen(false)}>关闭</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};