feat: Ansible 完整工作流页面(模板→主机→预览→执行→监控)

**新组件 AnsibleWorkflow.tsx(~670行):**
- Step 1: 模板选择 — 卡片网格,按分类/标签搜索过滤
- Step 2: 主机匹配 — 正则/前缀/模糊搜索,checkable 表格,全选
- Step 3: 确认执行 — Playbook 摘要 + 主机列表 + YAML 预览
- Step 4: 执行监控 — 状态指示器 + 终端输出 + 耗时统计
- 执行记录 Tab — 历史列表 + 输出展开

**路由调整:**
- /ansible → AnsibleWorkflow(主工作流)
- /ansible/manage → AnsibleManager(模板 CRUD)

**类型补全:**
- ExecutionRecord 增加 triggered_by 字段
This commit is contained in:
attacker committed 2026-06-19 13:41:04 +00:00
1 parent c9b6647831
commit f05cf71121
5 files changed
+699 -6

No files matched your search

@@ -0,0 +1,692 @@
import { useState, useEffect, useCallback } 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 { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from 'sonner';
import { ansibleApi } from '@/services/api';
import {
Play, Server, ChevronRight, ChevronLeft, CheckCircle, Loader2,
Search, RefreshCw, Terminal, Clock, XCircle, FileText, Zap,
Boxes, Shield, Database, Activity, Wrench, Network,
} from 'lucide-react';
import {
mapPlaybook, categories, getStatusBadge, formatDate,
type Playbook, type ExecutionRecord,
} from './ansible-helpers';
import { useConfirm } from '@/components/ui/confirm-dialog';
// ============ Types ============
interface MatchedHost {
id: number;
hostname: string;
ip: string;
os: string;
status: string;
has_auth: boolean;
tags: string;
}
type Step = 'template' | 'hosts' | 'review' | 'execute';
// ============ Icons per category ============
const catIcons: Record<string, React.ComponentType<any>> = {
deployment: Boxes,
configuration: Wrench,
maintenance: Activity,
security: Shield,
backup: Database,
monitoring: Network,
};
// ============ Main Component ============
export default function AnsibleWorkflow() {
const _confirm = useConfirm();
// ---- State ----
const [step, setStep] = useState<Step>('template');
const [activeTab, setActiveTab] = useState('workflow');
// Template
const [playbooks, setPlaybooks] = useState<Playbook[]>([]);
const [loadingPB, setLoadingPB] = useState(false);
const [searchPB, setSearchPB] = useState('');
const [catFilter, setCatFilter] = useState('all');
const [selectedPB, setSelectedPB] = useState<Playbook | null>(null);
// Hosts
const [hostPattern, setHostPattern] = useState('');
const [matchType, setMatchType] = useState('contains');
const [hosts, setHosts] = useState<MatchedHost[]>([]);
const [hostsTotal, setHostsTotal] = useState(0);
const [loadingHosts, setLoadingHosts] = useState(false);
const [selectedHosts, setSelectedHosts] = useState<Set<number>>(new Set());
const [hostSearch, setHostSearch] = useState('');
// Execution
const [executing, setExecuting] = useState(false);
const [execStatus, setExecStatus] = useState('');
const [execOutput, setExecOutput] = useState('');
const [execDuration, setExecDuration] = useState(0);
const [execJobId, setExecJobId] = useState<number | null>(null);
// History
const [executions, setExecutions] = useState<ExecutionRecord[]>([]);
const [loadingExecs, setLoadingExecs] = useState(false);
// ============ Data Fetching ============
const fetchPlaybooks = useCallback(async () => {
setLoadingPB(true);
try {
const res = await ansibleApi.getPlaybooks({ page_size: 100 });
const items = (res as any)?.list || [];
setPlaybooks(items.map(mapPlaybook));
} catch { toast.error('获取 Playbook 失败'); }
finally { setLoadingPB(false); }
}, []);
const fetchExecutions = useCallback(async () => {
setLoadingExecs(true);
try {
const res = await ansibleApi.getJobs({ page_size: 30 });
setExecutions((res as any)?.list || []);
} catch { /* silent */ }
finally { setLoadingExecs(false); }
}, []);
useEffect(() => { fetchPlaybooks(); fetchExecutions(); }, []);
// ============ Host Matching ============
const matchHosts = useCallback(async (pattern: string, type: string) => {
if (!pattern && type === 'contains') return; // skip empty contains
setLoadingHosts(true);
try {
const res = await ansibleApi.matchHosts({
pattern: pattern || undefined,
match_type: type,
page_size: 100,
});
const list = (res as any)?.list || [];
setHosts(list);
setHostsTotal((res as any)?.total || list.length);
} catch { toast.error('主机匹配失败'); }
finally { setLoadingHosts(false); }
}, []);
// Auto-match when entering hosts step
useEffect(() => {
if (step === 'hosts') matchHosts(hostPattern, matchType);
}, [step]);
// ============ Execution ============
const executePlaybook = async () => {
if (!selectedPB) return;
const hostList = Array.from(selectedHosts).map(id => {
const h = hosts.find(x => x.id === id);
return h?.hostname || h?.ip || String(id);
});
const ok = await _confirm({
title: '确认执行',
description: `将在 ${hostList.length} 台主机上执行「${selectedPB.name}」`,
confirmText: '开始执行',
});
if (!ok) return;
setStep('execute');
setExecuting(true);
setExecStatus('running');
setExecOutput('正在连接目标主机...\n');
const startTime = Date.now();
try {
const res = await ansibleApi.executePlaybook(
selectedPB.id,
hostList.length > 0 ? hostList : undefined
);
const job = (res as any)?.data ?? res;
setExecJobId(job?.id);
setExecStatus(job?.status || 'completed');
setExecOutput(job?.output || '执行完成(无输出)');
setExecDuration(job?.duration || (Date.now() - startTime));
} catch (e: any) {
setExecStatus('failed');
setExecOutput(`执行失败: ${e?.message || '未知错误'}`);
setExecDuration(Date.now() - startTime);
} finally {
setExecuting(false);
fetchExecutions();
}
};
// ============ Render Helpers ============
const toggleHost = (id: number) => {
setSelectedHosts(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleAllHosts = () => {
if (selectedHosts.size === filteredHosts.length && filteredHosts.length > 0) {
setSelectedHosts(new Set());
} else {
setSelectedHosts(new Set(filteredHosts.map(h => h.id)));
}
};
const filteredHosts = hosts.filter(h =>
h.hostname.toLowerCase().includes(hostSearch.toLowerCase()) ||
h.ip.includes(hostSearch)
);
const filteredPlaybooks = playbooks.filter(pb => {
const q = searchPB.toLowerCase();
const match = pb.name.toLowerCase().includes(q) ||
pb.description.toLowerCase().includes(q) ||
pb.tags.some(t => t.toLowerCase().includes(q));
return match && (catFilter === 'all' || pb.category === catFilter);
});
const stepsList: { key: Step; label: string; icon: React.ComponentType<any> }[] = [
{ key: 'template', label: '选择模板', icon: FileText },
{ key: 'hosts', label: '匹配主机', icon: Server },
{ key: 'review', label: '确认执行', icon: CheckCircle },
{ key: 'execute', label: '执行监控', icon: Terminal },
];
const currentStepIdx = stepsList.findIndex(s => s.key === step);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">Ansible 自动化部署</h1>
<p className="text-sm text-slate-500 mt-1">模板 → 匹配主机 → 执行部署 → 历史记录</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-[280px] grid-cols-2">
<TabsTrigger value="workflow">工作流</TabsTrigger>
<TabsTrigger value="history">执行记录</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Step Indicator */}
{activeTab === 'workflow' && (
<div className="flex items-center gap-2">
{stepsList.map((s, i) => (
<div key={s.key} className="flex items-center gap-2">
<button
onClick={() => i <= currentStepIdx && setStep(s.key)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
i === currentStepIdx
? 'bg-blue-600 text-white'
: i < currentStepIdx
? 'bg-emerald-500/20 text-emerald-400 cursor-pointer hover:bg-emerald-500/30'
: 'bg-slate-800 text-slate-500'
}`}
>
{i < currentStepIdx ? (
<CheckCircle className="h-4 w-4" />
) : (
<s.icon className="h-4 w-4" />
)}
{s.label}
</button>
{i < stepsList.length - 1 && (
<ChevronRight className="h-4 w-4 text-slate-600" />
)}
</div>
))}
</div>
)}
{/* ============ Workflow Content ============ */}
{activeTab === 'workflow' && (
<>
{/* Step 1: Template Selection */}
{step === 'template' && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-400" />
选择 Playbook 模板
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索名称、标签…"
value={searchPB}
onChange={e => setSearchPB(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700"
/>
</div>
<Select value={catFilter} onValueChange={setCatFilter}>
<SelectTrigger className="w-36 bg-slate-800 border-slate-700">
<SelectValue placeholder="分类" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部分类</SelectItem>
{categories.map(c => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={fetchPlaybooks} className="border-slate-700">
<RefreshCw className="h-4 w-4 mr-2" />刷新
</Button>
</div>
{loadingPB ? (
<div className="text-center py-12 text-slate-500">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />加载中…
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredPlaybooks.map(pb => {
const Icon = catIcons[pb.category] || FileText;
const selected = selectedPB?.id === pb.id;
return (
<div
key={pb.id}
onClick={() => setSelectedPB(selected ? null : pb)}
className={`p-4 rounded-lg border cursor-pointer transition-all ${
selected
? 'border-blue-500 bg-blue-500/10 ring-1 ring-blue-500/30'
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
}`}
>
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${selected ? 'bg-blue-500/20' : 'bg-slate-700/50'}`}>
<Icon className={`h-5 w-5 ${selected ? 'text-blue-400' : 'text-slate-400'}`} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-slate-100 text-sm truncate">{pb.name}</h3>
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{pb.description}</p>
<div className="flex flex-wrap gap-1 mt-2">
{pb.tags.slice(0, 3).map(t => (
<Badge key={t} variant="outline" className="text-[10px] border-slate-700 text-slate-500 px-1.5 py-0">
{t}
</Badge>
))}
<Badge className="text-[10px] bg-slate-700/50 text-slate-400 px-1.5 py-0">
{categories.find(c => c.value === pb.category)?.label || pb.category}
</Badge>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="flex justify-end">
<Button
onClick={() => { setStep('hosts'); }}
disabled={!selectedPB}
className="gap-2"
>
下一步:匹配主机 <ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 2: Host Matching */}
{step === 'hosts' && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<Server className="h-5 w-5 text-emerald-400" />
匹配目标主机
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="主机名/IP 匹配(支持正则):web* / ^k8s-.* / 10.10.10."
value={hostPattern}
onChange={e => setHostPattern(e.target.value)}
onKeyDown={e => e.key === 'Enter' && matchHosts(hostPattern, matchType)}
className="pl-10 bg-slate-800 border-slate-700 font-mono text-sm"
/>
</div>
<Select value={matchType} onValueChange={v => { setMatchType(v); matchHosts(hostPattern, v); }}>
<SelectTrigger className="w-28 bg-slate-800 border-slate-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="prefix">前缀</SelectItem>
<SelectItem value="contains">模糊</SelectItem>
<SelectItem value="regex">正则</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => matchHosts(hostPattern, matchType)} className="border-slate-700">
<Search className="h-4 w-4 mr-2" />匹配
</Button>
</div>
{/* Host table */}
{loadingHosts ? (
<div className="text-center py-8 text-slate-500">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />匹配中…
</div>
) : (
<>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-400">
匹配 <span className="text-slate-200 font-medium">{hostsTotal}</span> 台主机
{selectedHosts.size > 0 && (
<span className="text-emerald-400 ml-2">(已选 {selectedHosts.size} 台)</span>
)}
</span>
{filteredHosts.length > 0 && (
<div className="flex items-center gap-2">
<Checkbox
checked={selectedHosts.size === filteredHosts.length && filteredHosts.length > 0}
onCheckedChange={toggleAllHosts}
/>
<span className="text-xs text-slate-500">全选</span>
</div>
)}
</div>
<div className="border border-slate-800 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="w-10 text-slate-500">选</TableHead>
<TableHead className="text-slate-400">主机名</TableHead>
<TableHead className="text-slate-400">IP</TableHead>
<TableHead className="text-slate-400">OS</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">认证</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredHosts.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
输入匹配模式后点击"匹配"预览主机
</TableCell>
</TableRow>
) : (
filteredHosts.map(h => (
<TableRow
key={h.id}
className="border-slate-800 hover:bg-slate-800/30 cursor-pointer"
onClick={() => toggleHost(h.id)}
>
<TableCell>
<Checkbox checked={selectedHosts.has(h.id)} />
</TableCell>
<TableCell className="font-medium text-slate-200">{h.hostname}</TableCell>
<TableCell className="text-slate-400 font-mono text-sm">{h.ip}</TableCell>
<TableCell className="text-slate-500 text-sm">{h.os || '-'}</TableCell>
<TableCell>
<Badge className={h.status === 'running' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-500/20 text-slate-400'}>
{h.status === 'running' ? '运行中' : h.status || '未知'}
</Badge>
</TableCell>
<TableCell>
{h.has_auth ? (
<Badge className="bg-emerald-500/20 text-emerald-400">已配置</Badge>
) : (
<Badge className="bg-yellow-500/20 text-yellow-400">未配置</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</>
)}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep('template')} className="gap-2 border-slate-700">
<ChevronLeft className="h-4 w-4" />上一步
</Button>
<Button
onClick={() => setStep('review')}
disabled={selectedHosts.size === 0}
className="gap-2"
>
下一步:确认执行 <ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Review & Confirm */}
{step === 'review' && selectedPB && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-amber-400" />
确认执行
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Playbook summary */}
<div className="p-4 rounded-lg bg-slate-800/50 border border-slate-700">
<h3 className="text-sm font-medium text-slate-300 mb-2">Playbook</h3>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/20">
<FileText className="h-5 w-5 text-blue-400" />
</div>
<div>
<p className="text-slate-100 font-medium">{selectedPB.name}</p>
<p className="text-xs text-slate-500">{selectedPB.description}</p>
</div>
</div>
</div>
{/* Host list summary */}
<div className="p-4 rounded-lg bg-slate-800/50 border border-slate-700">
<h3 className="text-sm font-medium text-slate-300 mb-2">
目标主机 <span className="text-emerald-400">({selectedHosts.size} 台)</span>
</h3>
<div className="flex flex-wrap gap-2">
{Array.from(selectedHosts).map(id => {
const h = hosts.find(x => x.id === id);
return (
<Badge key={id} variant="outline" className="border-slate-600 text-slate-300 py-1 px-3">
<Server className="h-3 w-3 mr-1 text-emerald-400" />
{h?.hostname || `ID:${id}`}
<span className="text-slate-500 ml-1 text-xs">({h?.ip})</span>
</Badge>
);
})}
</div>
</div>
{/* Playbook content preview */}
<details className="p-4 rounded-lg bg-slate-800/50 border border-slate-700">
<summary className="text-sm font-medium text-slate-400 cursor-pointer hover:text-slate-300">
📄 查看 Playbook YAML 内容
</summary>
<pre className="mt-3 text-xs text-slate-400 bg-slate-950 p-3 rounded overflow-x-auto max-h-64">
{selectedPB.content || '(空)'}
</pre>
</details>
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep('hosts')} className="gap-2 border-slate-700">
<ChevronLeft className="h-4 w-4" />上一步
</Button>
<Button onClick={executePlaybook} className="gap-2 bg-emerald-600 hover:bg-emerald-700">
<Zap className="h-4 w-4" />开始执行
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Execution & Monitor */}
{step === 'execute' && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<Terminal className="h-5 w-5 text-cyan-400" />
执行监控
{executing && <Loader2 className="h-4 w-4 animate-spin text-blue-400" />}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Status indicator */}
<div className="flex items-center gap-4">
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg ${
execStatus === 'success' ? 'bg-emerald-500/10 border border-emerald-500/30' :
execStatus === 'failed' ? 'bg-red-500/10 border border-red-500/30' :
'bg-blue-500/10 border border-blue-500/30'
}`}>
{execStatus === 'success' ? <CheckCircle className="h-5 w-5 text-emerald-400" /> :
execStatus === 'failed' ? <XCircle className="h-5 w-5 text-red-400" /> :
<Loader2 className="h-5 w-5 animate-spin text-blue-400" />}
<span className={`font-medium ${
execStatus === 'success' ? 'text-emerald-400' :
execStatus === 'failed' ? 'text-red-400' : 'text-blue-400'
}`}>
{execStatus === 'success' ? '执行成功' :
execStatus === 'failed' ? '执行失败' :
executing ? '执行中…' : '等待执行'}
</span>
</div>
{execDuration > 0 && (
<span className="text-sm text-slate-500 flex items-center gap-1">
<Clock className="h-3 w-3" />
{execDuration >= 1000
? `${(execDuration / 1000).toFixed(1)}s`
: `${execDuration}ms`}
</span>
)}
</div>
{/* Output terminal */}
<div className="bg-slate-950 border border-slate-800 rounded-lg p-4 font-mono text-sm text-slate-300 max-h-96 overflow-auto whitespace-pre-wrap">
{execOutput || (executing ? '等待输出…' : '无输出')}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => { setStep('template'); setSelectedPB(null); setSelectedHosts(new Set()); setExecOutput(''); setExecStatus(''); }}
className="gap-2 border-slate-700"
>
<Zap className="h-4 w-4" />新任务
</Button>
{execJobId && (
<Button
variant="outline"
onClick={() => { setActiveTab('history'); }}
className="gap-2 border-slate-700"
>
<Clock className="h-4 w-4" />查看历史
</Button>
)}
</div>
</CardContent>
</Card>
)}
</>
)}
{/* ============ History Tab ============ */}
{activeTab === 'history' && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-slate-100 flex items-center gap-2">
<Clock className="h-5 w-5 text-slate-400" />
执行记录
</CardTitle>
<Button variant="outline" size="sm" onClick={fetchExecutions} className="border-slate-700 h-8">
<RefreshCw className="h-3 w-3 mr-1" />刷新
</Button>
</div>
</CardHeader>
<CardContent>
{loadingExecs ? (
<div className="text-center py-8 text-slate-500">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />加载中…
</div>
) : executions.length === 0 ? (
<div className="text-center py-8 text-slate-500">暂无执行记录</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Playbook</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>
{executions.map(e => (
<TableRow key={e.id} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{e.task_name}</TableCell>
<TableCell>{getStatusBadge(e.status)}</TableCell>
<TableCell className="text-slate-400 text-sm">
{e.duration >= 1000 ? `${(e.duration / 1000).toFixed(1)}s` : `${e.duration}ms`}
</TableCell>
<TableCell className="text-slate-500 text-sm">{e.triggered_by || '-'}</TableCell>
<TableCell className="text-slate-500 text-sm">{formatDate(e.created_at)}</TableCell>
<TableCell>
{e.output ? (
<details>
<summary className="text-xs text-blue-400 cursor-pointer hover:text-blue-300">
查看输出
</summary>
<pre className="mt-2 text-xs text-slate-400 bg-slate-950 p-2 rounded max-h-32 overflow-auto">
{e.output.slice(0, 2000)}
</pre>
</details>
) : (
<span className="text-xs text-slate-600">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
</div>
);
}
@@ -25,6 +25,7 @@ export interface ExecutionRecord {
status: string;
output: string;
duration: number;
triggered_by: string;
started_at: string | null;
finished_at: string | null;
created_at: string;
@@ -52,8 +52,6 @@ export function mapScript(raw: any): Script {
export 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> = {