refactor(frontend): api/components/pages 目录架构统一对齐菜单域 + 日志中心修复
1. 目录架构统一(API = components = 菜单域,一域一目录 + barrel) - log/ 提为独立顶层域(日志中心在菜单中与监控中心平级,不再塞在 monitor/) - task/ 拆分原 automation.ts → cron-job / script / remote-exec - aiops/ 合并 ai.ts + agentCli.ts → assistant + agent-cli - agent/ approval/ audit/ 提升为域目录 - auth 拆分:authApi(认证自服务) + system/user.ts(userApi+ldapApi 管理端) - components/automation → components/task,菜单"自动化运维"→"作业任务" - pages/ 按域分子目录(approval/audit/monitor),删孤儿 AuditPage.tsx - 所有 API 符号名不变,组件经 @/api 聚合引用,运行时行为零变化 2. Loki 查询无返回修复:aiops 容器 DNS 解析外网域名失败 (宿主机 systemd-resolved 转发链路异常),docker-compose 显式配 dns 上游 3. ES 查询框布局:工具栏单行挤 7 控件(ES 多 150px 索引框),拆两行—— 查询输入独占一行(+查询按钮),次级控件换行 flex-wrap Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
da01cbb119
commit
c0ee8e4ddd
74 files changed
+229
-216
No files matched your search
@@ -0,0 +1,396 @@
|
||||
// ScriptManager — 脚本库与执行记录主组件 // Logan.Li
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Search, RotateCcw, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { scriptApi, taskExecutionApi } from '@/api';
|
||||
import {
|
||||
type Script, type ExecutionRecord, mapScript, scriptTypes, genCode,
|
||||
} from './script-helpers';
|
||||
import type { ScriptFormData } from './ScriptFormDialog';
|
||||
import ScriptFormDialog from './ScriptFormDialog';
|
||||
import ScriptDetailDialog from './ScriptDetailDialog';
|
||||
import ScriptLogsDialog from './ScriptLogsDialog';
|
||||
import ScriptCard from './ScriptCard';
|
||||
import ScriptExecutionsTable from './ScriptExecutionsTable';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
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 [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
// 弹窗状态
|
||||
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<ScriptFormData>({
|
||||
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 JsonData)?.list || [];
|
||||
setScripts(items.map(mapScript));
|
||||
} catch {
|
||||
toast.error('获取脚本列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchExecutions = async () => {
|
||||
try {
|
||||
const res = await taskExecutionApi.getExecutions({ task_type: 'script', page_size: 50 });
|
||||
const items = (res as JsonData)?.list || [];
|
||||
setExecutions(items);
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
setSelectedScript(sc);
|
||||
setFormData({
|
||||
name: sc.name,
|
||||
description: sc.description,
|
||||
content: sc.content,
|
||||
type: sc.type,
|
||||
timeout: sc.timeout,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
setDetailOpen(false);
|
||||
};
|
||||
|
||||
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) {
|
||||
await scriptApi.updateScript(editingId, payload);
|
||||
toast.success('脚本更新成功');
|
||||
} else {
|
||||
await scriptApi.createScript(payload);
|
||||
toast.success('脚本创建成功');
|
||||
}
|
||||
setEditorOpen(false);
|
||||
fetchScripts();
|
||||
} catch {
|
||||
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 {
|
||||
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 {
|
||||
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 JsonData)?.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 {
|
||||
toast.error('执行请求失败');
|
||||
} finally {
|
||||
setExecuting(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 toggleSelect = (id: number) => {
|
||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const allSelected = filteredScripts.length > 0 && selectedIds.length === filteredScripts.length;
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
setSelectedIds(allSelected ? [] : filteredScripts.map(sc => sc.id));
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const ok = await _confirm({
|
||||
title: '批量删除脚本',
|
||||
description: `确定删除选中的 ${selectedIds.length} 个脚本吗?此操作不可恢复。`,
|
||||
confirmText: '批量删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await scriptApi.batchDelete(selectedIds);
|
||||
toast.success(`已删除 ${selectedIds.length} 个脚本`);
|
||||
setSelectedIds([]);
|
||||
fetchScripts();
|
||||
} catch {
|
||||
toast.error('批量删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<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>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-2.5 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">
|
||||
已选择 {selectedIds.length} 项 / 共 {filteredScripts.length} 项
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectedIds([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
<Trash2 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) => (
|
||||
<ScriptCard
|
||||
key={sc.id}
|
||||
script={sc}
|
||||
selected={selectedIds.includes(sc.id)}
|
||||
executing={executing}
|
||||
onToggleSelect={() => toggleSelect(sc.id)}
|
||||
onView={() => { setSelectedScript(sc); setDetailOpen(true); }}
|
||||
onExecute={() => handleExecute(sc)}
|
||||
onEdit={() => openEdit(sc)}
|
||||
onClone={() => handleClone(sc)}
|
||||
onDelete={() => handleDelete(sc)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ============ 执行记录 ============ */}
|
||||
<TabsContent value="jobs">
|
||||
<ScriptExecutionsTable
|
||||
executions={executions}
|
||||
onRefresh={fetchExecutions}
|
||||
onViewLogs={(job) => { setSelectedExec(job); setLogsOpen(true); }}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ===== 新建/编辑脚本对话框 ===== */}
|
||||
<ScriptFormDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
editingId={editingId}
|
||||
editingCode={selectedScript?.code || ''}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onTypeChange={handleTypeChange}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* ===== 查看脚本内容 ===== */}
|
||||
<ScriptDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
script={selectedScript}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
|
||||
{/* ===== 执行日志 ===== */}
|
||||
<ScriptLogsDialog
|
||||
open={logsOpen}
|
||||
onOpenChange={setLogsOpen}
|
||||
exec={selectedExec}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user