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:
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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user