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"`
|
Name string `gorm:"size:128;not null" json:"name"`
|
||||||
Description string `gorm:"size:500" json:"description"`
|
Description string `gorm:"size:500" json:"description"`
|
||||||
CronExpr string `gorm:"size:64;not null" json:"cron_expr"`
|
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"`
|
Content string `gorm:"type:text" json:"content"`
|
||||||
ScriptID *uint `json:"script_id"`
|
ScriptID *uint `json:"script_id"`
|
||||||
TargetType string `gorm:"size:32;default:server" json:"target_type"` // server, group, tag
|
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"`
|
Name string `json:"name" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CronExpr string `json:"cron_expr" binding:"required"`
|
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"`
|
Content string `json:"content"`
|
||||||
ScriptID *uint `json:"script_id"`
|
ScriptID *uint `json:"script_id"`
|
||||||
TargetType string `json:"target_type" binding:"omitempty,oneof=server group tag"`
|
TargetType string `json:"target_type" binding:"omitempty,oneof=server group tag"`
|
||||||
@@ -138,7 +138,7 @@ type CreateScriptRequest struct {
|
|||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Code string `json:"code" binding:"required"`
|
Code string `json:"code" binding:"required"`
|
||||||
Description string `json:"description"`
|
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"`
|
Content string `json:"content" binding:"required"`
|
||||||
Params string `json:"params"`
|
Params string `json:"params"`
|
||||||
Timeout int `json:"timeout"`
|
Timeout int `json:"timeout"`
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { PipelineManager } from '@/components/deploy/PipelineManager';
|
|||||||
import { ReleaseManager } from '@/components/deploy/ReleaseManager';
|
import { ReleaseManager } from '@/components/deploy/ReleaseManager';
|
||||||
import { CronJobManager } from '@/components/automation/CronJobManager';
|
import { CronJobManager } from '@/components/automation/CronJobManager';
|
||||||
import AnsibleManager from '@/components/automation/AnsibleManager';
|
import AnsibleManager from '@/components/automation/AnsibleManager';
|
||||||
|
import AnsibleWorkflow from '@/components/automation/AnsibleWorkflow';
|
||||||
import { AIOpsAssistant } from '@/components/aiops/AIOpsAssistant';
|
import { AIOpsAssistant } from '@/components/aiops/AIOpsAssistant';
|
||||||
import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer';
|
import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer';
|
||||||
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
|
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
|
||||||
@@ -150,7 +151,8 @@ function App() {
|
|||||||
{/* 自动化运维 */}
|
{/* 自动化运维 */}
|
||||||
<Route path="cronjobs" element={<CronJobManager />} />
|
<Route path="cronjobs" element={<CronJobManager />} />
|
||||||
<Route path="scripts" element={<ScriptManager />} />
|
<Route path="scripts" element={<ScriptManager />} />
|
||||||
<Route path="ansible" element={<AnsibleManager />} />
|
<Route path="ansible" element={<AnsibleWorkflow />} />
|
||||||
|
<Route path="ansible/manage" element={<AnsibleManager />} />
|
||||||
{/* AIOps智能 */}
|
{/* AIOps智能 */}
|
||||||
<Route path="ai-assistant" element={<AIOpsAssistant />} />
|
<Route path="ai-assistant" element={<AIOpsAssistant />} />
|
||||||
<Route path="ai-alert-analyzer" element={<AIAlertAnalyzer />} />
|
<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;
|
status: string;
|
||||||
output: string;
|
output: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
triggered_by: string;
|
||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
finished_at: string | null;
|
finished_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ export function mapScript(raw: any): Script {
|
|||||||
export const scriptTypes = [
|
export const scriptTypes = [
|
||||||
{ value: 'shell', label: 'Shell', sample: '#!/bin/bash\n# 系统巡检脚本\necho "===== 系统信息 ====="\nuname -a\necho ""\necho "===== 磁盘使用 ====="\ndf -h\necho ""\necho "===== 内存使用 ====="\nfree -h\n' },
|
{ 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: '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> = {
|
const typeColors: Record<string, string> = {
|
||||||
|
|||||||
Reference in new issue
Block a user