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:
1 parent
c9b6647831
commit
f05cf71121
5 files changed
+699
-6
No files matched your search
@@ -11,7 +11,7 @@ type CronJob struct {
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Description string `gorm:"size:500" json:"description"`
|
||||
CronExpr string `gorm:"size:64;not null" json:"cron_expr"`
|
||||
Type string `gorm:"size:32;not null" json:"type"` // script, command, playbook
|
||||
Type string `gorm:"size:32;not null" json:"type"` // shell, python, command
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
ScriptID *uint `json:"script_id"`
|
||||
TargetType string `gorm:"size:32;default:server" json:"target_type"` // server, group, tag
|
||||
@@ -124,7 +124,7 @@ type CreateCronJobRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
CronExpr string `json:"cron_expr" binding:"required"`
|
||||
Type string `json:"type" binding:"required,oneof=script command playbook"`
|
||||
Type string `json:"type" binding:"required,oneof=shell python command"`
|
||||
Content string `json:"content"`
|
||||
ScriptID *uint `json:"script_id"`
|
||||
TargetType string `json:"target_type" binding:"omitempty,oneof=server group tag"`
|
||||
@@ -138,7 +138,7 @@ type CreateScriptRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=shell python powershell yaml"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=shell python"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Params string `json:"params"`
|
||||
Timeout int `json:"timeout"`
|
||||
|
||||
@@ -33,6 +33,7 @@ import { PipelineManager } from '@/components/deploy/PipelineManager';
|
||||
import { ReleaseManager } from '@/components/deploy/ReleaseManager';
|
||||
import { CronJobManager } from '@/components/automation/CronJobManager';
|
||||
import AnsibleManager from '@/components/automation/AnsibleManager';
|
||||
import AnsibleWorkflow from '@/components/automation/AnsibleWorkflow';
|
||||
import { AIOpsAssistant } from '@/components/aiops/AIOpsAssistant';
|
||||
import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer';
|
||||
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
|
||||
@@ -150,7 +151,8 @@ function App() {
|
||||
{/* 自动化运维 */}
|
||||
<Route path="cronjobs" element={<CronJobManager />} />
|
||||
<Route path="scripts" element={<ScriptManager />} />
|
||||
<Route path="ansible" element={<AnsibleManager />} />
|
||||
<Route path="ansible" element={<AnsibleWorkflow />} />
|
||||
<Route path="ansible/manage" element={<AnsibleManager />} />
|
||||
{/* AIOps智能 */}
|
||||
<Route path="ai-assistant" element={<AIOpsAssistant />} />
|
||||
<Route path="ai-alert-analyzer" element={<AIAlertAnalyzer />} />
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in new issue
Block a user