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:
attackerandClaude committed 2026-06-28 06:06:48 +00:00
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>
);
}